diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs index 1c821f9681..5eaa75002c 100644 --- a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs +++ b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs @@ -48,7 +48,7 @@ public class ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions : Id = AuthSchemeName, } }, - new string[] { } + [] } } }; diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs index 0eeeb4d6da..15ecaa1423 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs @@ -185,7 +185,7 @@ internal sealed class ApiMediaQueryService : IApiMediaQueryService } - private Attempt, ApiMediaQueryOperationStatus> PagedResult(IEnumerable children, int skip, int take) + private static Attempt, ApiMediaQueryOperationStatus> PagedResult(IEnumerable children, int skip, int take) { IPublishedContent[] childrenAsArray = children as IPublishedContent[] ?? children.ToArray(); var result = new PagedModel diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/References/ReferencedByDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/References/ReferencedByDataTypeController.cs new file mode 100644 index 0000000000..b36815d408 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/References/ReferencedByDataTypeController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.DataType.References; + +[ApiVersion("1.0")] +public class ReferencedByDataTypeController : DataTypeControllerBase +{ + private readonly IDataTypeService _dataTypeService; + private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; + + public ReferencedByDataTypeController(IDataTypeService dataTypeService, IRelationTypePresentationFactory relationTypePresentationFactory) + { + _dataTypeService = dataTypeService; + _relationTypePresentationFactory = relationTypePresentationFactory; + } + + /// + /// Gets a paged list of references for the current data type, so you can see where it is being used. + /// + [HttpGet("{id:guid}/referenced-by")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> ReferencedBy( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _dataTypeService.GetPagedRelationsAsync(id, skip, take); + + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + }; + + return pagedViewModel; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/ReferencesDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/ReferencesDataTypeController.cs index c25586e93c..0eee28e49b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/ReferencesDataTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/ReferencesDataTypeController.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.DataType; [ApiVersion("1.0")] +[Obsolete("Please use ReferencedByDataTypeController and the referenced-by endpoint. Scheduled for removal in Umbraco 17.")] public class ReferencesDataTypeController : DataTypeControllerBase { private readonly IDataTypeService _dataTypeService; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs index 52b2f34d7f..10f1931313 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Controllers.Content; @@ -140,6 +140,10 @@ public abstract class DocumentControllerBase : ContentControllerBase .WithDetail( "An unspecified error occurred while (un)publishing. Please check the logs for additional information.") .Build()), + ContentPublishingOperationStatus.TaskResultNotFound => NotFound(problemDetailsBuilder + .WithTitle("The result of the submitted task could not be found") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."), }); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs index 869bc4c880..f9d9681f07 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs @@ -35,7 +35,7 @@ public class PublishDocumentWithDescendantsController : DocumentControllerBase [HttpPut("{id:guid}/publish-with-descendants")] [MapToApiVersion("1.0")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(PublishWithDescendantsResultModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task PublishWithDescendants(CancellationToken cancellationToken, Guid id, PublishDocumentWithDescendantsRequestModel requestModel) @@ -54,10 +54,15 @@ public class PublishDocumentWithDescendantsController : DocumentControllerBase id, requestModel.Cultures, BuildPublishBranchFilter(requestModel), - CurrentUserKey(_backOfficeSecurityAccessor)); + CurrentUserKey(_backOfficeSecurityAccessor), + true); - return attempt.Success - ? Ok() + return attempt.Success && attempt.Result.AcceptedTaskId.HasValue + ? Ok(new PublishWithDescendantsResultModel + { + TaskId = attempt.Result.AcceptedTaskId.Value, + IsComplete = false + }) : DocumentPublishingOperationStatusResult(attempt.Status, failedBranchItems: attempt.Result.FailedItems); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs new file mode 100644 index 0000000000..9a499ede1e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs @@ -0,0 +1,69 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +[ApiVersion("1.0")] +public class PublishDocumentWithDescendantsResultController : DocumentControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IContentPublishingService _contentPublishingService; + + public PublishDocumentWithDescendantsResultController( + IAuthorizationService authorizationService, + IContentPublishingService contentPublishingService) + { + _authorizationService = authorizationService; + _contentPublishingService = contentPublishingService; + } + + [HttpGet("{id:guid}/publish-with-descendants/result/{taskId:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PublishWithDescendantsResultModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task PublishWithDescendantsResult(CancellationToken cancellationToken, Guid id, Guid taskId) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.Branch(ActionPublish.ActionLetter, id), + AuthorizationPolicies.ContentPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + // Check if the publishing task has completed, if not, return the status. + var isPublishing = await _contentPublishingService.IsPublishingBranchAsync(taskId); + if (isPublishing) + { + return Ok(new PublishWithDescendantsResultModel + { + TaskId = taskId, + IsComplete = false + }); + }; + + // If completed, get the result and return the status. + Attempt attempt = await _contentPublishingService.GetPublishBranchResultAsync(taskId); + return attempt.Success + ? Ok(new PublishWithDescendantsResultModel + { + TaskId = taskId, + IsComplete = true + }) + : DocumentPublishingOperationStatusResult(attempt.Status, failedBranchItems: attempt.Result.FailedItems); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/ReferencedByDocumentRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/ReferencedByDocumentRecycleBinController.cs new file mode 100644 index 0000000000..2b94fb443f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/ReferencedByDocumentRecycleBinController.cs @@ -0,0 +1,50 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Document.RecycleBin; + +[ApiVersion("1.0")] +public class ReferencedByDocumentRecycleBinController : DocumentRecycleBinControllerBase +{ + private readonly ITrackedReferencesService _trackedReferencesService; + private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; + + public ReferencedByDocumentRecycleBinController( + IEntityService entityService, + IDocumentPresentationFactory documentPresentationFactory, + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) + : base(entityService, documentPresentationFactory) + { + _trackedReferencesService = trackedReferencesService; + _relationTypePresentationFactory = relationTypePresentationFactory; + } + + /// + /// Gets a paged list of tracked references for all items in the document recycle bin, so you can see where an item is being used. + /// + [HttpGet("referenced-by")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> ReferencedBy( + CancellationToken cancellationToken, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes.Document, skip, take, true); + + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + }; + + return pagedViewModel; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/DocumentTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/DocumentTreeControllerBase.cs index 33e451bdc5..cacf862b57 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/DocumentTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/DocumentTreeControllerBase.cs @@ -4,6 +4,7 @@ using Umbraco.Cms.Api.Management.Controllers.Tree; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Routing; using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; @@ -33,7 +34,7 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa AppCaches appCaches, IBackOfficeSecurityAccessor backofficeSecurityAccessor, IDocumentPresentationFactory documentPresentationFactory) - : base(entityService, userStartNodeEntitiesService, dataTypeService) + : base(entityService, userStartNodeEntitiesService, dataTypeService) { _publicAccessService = publicAccessService; _appCaches = appCaches; @@ -52,6 +53,8 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa if (entity is IDocumentEntitySlim documentEntitySlim) { responseModel.IsProtected = _publicAccessService.IsProtected(entity.Path); + responseModel.Ancestors = EntityService.GetPathKeys(entity, omitSelf: true) + .Select(x => new ReferenceByIdModel(x)); responseModel.IsTrashed = entity.Trashed; responseModel.Id = entity.Key; responseModel.CreateDate = entity.CreateDate; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs index ecca82286c..7087e119f7 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs @@ -1,11 +1,14 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; @@ -16,15 +19,32 @@ public class ValidateCreateDocumentController : CreateDocumentControllerBase { private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; private readonly IContentEditingService _contentEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")] public ValidateCreateDocumentController( IAuthorizationService authorizationService, IDocumentEditingPresentationFactory documentEditingPresentationFactory, IContentEditingService contentEditingService) + : this( + authorizationService, + documentEditingPresentationFactory, + contentEditingService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [ActivatorUtilitiesConstructor] + public ValidateCreateDocumentController( + IAuthorizationService authorizationService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory, + IContentEditingService contentEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : base(authorizationService) { _documentEditingPresentationFactory = documentEditingPresentationFactory; _contentEditingService = contentEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } [HttpPost("validate")] @@ -36,7 +56,10 @@ public class ValidateCreateDocumentController : CreateDocumentControllerBase => await HandleRequest(requestModel, async () => { ContentCreateModel model = _documentEditingPresentationFactory.MapCreateModel(requestModel); - Attempt result = await _contentEditingService.ValidateCreateAsync(model); + Attempt result = + await _contentEditingService.ValidateCreateAsync( + model, + CurrentUserKey(_backOfficeSecurityAccessor)); return result.Success ? Ok() diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs index 40df8d6a83..bf06571c94 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs @@ -2,10 +2,13 @@ using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; @@ -16,15 +19,32 @@ public class ValidateUpdateDocumentController : UpdateDocumentControllerBase { private readonly IContentEditingService _contentEditingService; private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")] public ValidateUpdateDocumentController( IAuthorizationService authorizationService, IContentEditingService contentEditingService, IDocumentEditingPresentationFactory documentEditingPresentationFactory) + : this( + authorizationService, + contentEditingService, + documentEditingPresentationFactory, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [ActivatorUtilitiesConstructor] + public ValidateUpdateDocumentController( + IAuthorizationService authorizationService, + IContentEditingService contentEditingService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : base(authorizationService) { _contentEditingService = contentEditingService; _documentEditingPresentationFactory = documentEditingPresentationFactory; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } [HttpPut("{id:guid}/validate")] @@ -36,7 +56,11 @@ public class ValidateUpdateDocumentController : UpdateDocumentControllerBase => await HandleRequest(id, requestModel, async () => { ValidateContentUpdateModel model = _documentEditingPresentationFactory.MapValidateUpdateModel(requestModel); - Attempt result = await _contentEditingService.ValidateUpdateAsync(id, model); + Attempt result = + await _contentEditingService.ValidateUpdateAsync( + id, + model, + CurrentUserKey(_backOfficeSecurityAccessor)); return result.Success ? Ok() diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/ReferencedByMediaRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/ReferencedByMediaRecycleBinController.cs new file mode 100644 index 0000000000..a3c72184d7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/ReferencedByMediaRecycleBinController.cs @@ -0,0 +1,50 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Media.RecycleBin; + +[ApiVersion("1.0")] +public class ReferencedByMediaRecycleBinController : MediaRecycleBinControllerBase +{ + private readonly ITrackedReferencesService _trackedReferencesService; + private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; + + public ReferencedByMediaRecycleBinController( + IEntityService entityService, + IMediaPresentationFactory mediaPresentationFactory, + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) + : base(entityService, mediaPresentationFactory) + { + _trackedReferencesService = trackedReferencesService; + _relationTypePresentationFactory = relationTypePresentationFactory; + } + + /// + /// Gets a paged list of tracked references for all items in the media recycle bin, so you can see where an item is being used. + /// + [HttpGet("referenced-by")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> ReferencedBy( + CancellationToken cancellationToken, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes.Media, skip, take, true); + + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + }; + + return pagedViewModel; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs index 6083df32c1..392c9338db 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs @@ -21,7 +21,7 @@ public class RebuildPublishedCacheController : PublishedCacheControllerBase { var problemDetails = new ProblemDetails { - Title = "Database cache can not be rebuilt", + Title = "Database cache cannot be rebuilt", Detail = "The database cache is in the process of rebuilding.", Status = StatusCodes.Status400BadRequest, Type = "Error", diff --git a/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs index b33f63d7ac..ac808918fe 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Routing; @@ -17,6 +17,9 @@ public abstract class TemporaryFileControllerBase : ManagementApiControllerBase .WithTitle("File extension not allowed") .WithDetail("The file extension is not allowed.") .Build()), + TemporaryFileOperationStatus.InvalidFileName => BadRequest(problemDetailsBuilder + .WithTitle("The provided file name is not valid") + .Build()), TemporaryFileOperationStatus.KeyAlreadyUsed => BadRequest(problemDetailsBuilder .WithTitle("Key already used") .WithDetail("The specified key is already used.") diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs index d7a803b584..8c15708f1f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs @@ -42,11 +42,21 @@ public abstract class UserStartNodeTreeControllerBase : EntityTreeControl protected override IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems) { - IEntitySlim[] children = base.GetPagedChildEntities(parentKey, skip, take, out totalItems); - return UserHasRootAccess() || IgnoreUserStartNodes() - ? children - // Keeping the correct totalItems amount from GetPagedChildEntities - : CalculateAccessMap(() => _userStartNodeEntitiesService.ChildUserAccessEntities(children, UserStartNodePaths), out _); + if (UserHasRootAccess() || IgnoreUserStartNodes()) + { + return base.GetPagedChildEntities(parentKey, skip, take, out totalItems); + } + + IEnumerable userAccessEntities = _userStartNodeEntitiesService.ChildUserAccessEntities( + ItemObjectType, + UserStartNodePaths, + parentKey, + skip, + take, + ItemOrdering, + out totalItems); + + return CalculateAccessMap(() => userAccessEntities, out _); } protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ConfigurationUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ConfigurationUserController.cs index 7c9723c744..915158a92c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/ConfigurationUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ConfigurationUserController.cs @@ -1,15 +1,12 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Authorization; +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.User; -using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.User; [ApiVersion("1.0")] -[Authorize(Policy = AuthorizationPolicies.RequireAdminAccess)] public class ConfigurationUserController : UserControllerBase { private readonly IUserPresentationFactory _userPresentationFactory; diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs index 228952b469..b63704cbb2 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs @@ -1,5 +1,8 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.Document.Collection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -9,11 +12,23 @@ namespace Umbraco.Cms.Api.Management.Factories; public class DocumentCollectionPresentationFactory : ContentCollectionPresentationFactory, IDocumentCollectionPresentationFactory { private readonly IPublicAccessService _publicAccessService; + private readonly IEntityService _entityService; + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V17.")] public DocumentCollectionPresentationFactory(IUmbracoMapper mapper, IPublicAccessService publicAccessService) - : base(mapper) + : this( + mapper, + publicAccessService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [ActivatorUtilitiesConstructor] + public DocumentCollectionPresentationFactory(IUmbracoMapper mapper, IPublicAccessService publicAccessService, IEntityService entityService) + : base(mapper) { _publicAccessService = publicAccessService; + _entityService = entityService; } protected override Task SetUnmappedProperties(ListViewPagedModel contentCollection, List collectionResponseModels) @@ -27,6 +42,8 @@ public class DocumentCollectionPresentationFactory : ContentCollectionPresentati } item.IsProtected = _publicAccessService.IsProtected(matchingContentItem).Success; + item.Ancestors = _entityService.GetPathKeys(matchingContentItem, omitSelf: true) + .Select(x => new ReferenceByIdModel(x)); } return Task.CompletedTask; diff --git a/src/Umbraco.Cms.Api.Management/Factories/RedirectUrlPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/RedirectUrlPresentationFactory.cs index 8a62d299eb..8cb09bf03a 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/RedirectUrlPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/RedirectUrlPresentationFactory.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.RedirectUrlManagement; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Routing; @@ -22,6 +22,12 @@ public class RedirectUrlPresentationFactory : IRedirectUrlPresentationFactory var originalUrl = _publishedUrlProvider.GetUrlFromRoute(source.ContentId, source.Url, source.Culture); + // Even if the URL could not be extracted from the route, if we have a path as a the route for the original URL, we should display it. + if (originalUrl == "#" && source.Url.StartsWith('/')) + { + originalUrl = source.Url; + } + return new RedirectUrlResponseModel { OriginalUrl = originalUrl, diff --git a/src/Umbraco.Cms.Api.Management/Factories/RelationTypePresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/RelationTypePresentationFactory.cs index c45d03bc28..990312845c 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/RelationTypePresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/RelationTypePresentationFactory.cs @@ -56,9 +56,12 @@ public class RelationTypePresentationFactory : IRelationTypePresentationFactory IReferenceResponseModel[] result = relationItemModelsCollection.Select(relationItemModel => relationItemModel.NodeType switch { - Constants.UdiEntityType.Document => MapDocumentReference(relationItemModel, slimEntities), - Constants.UdiEntityType.Media => _umbracoMapper.Map(relationItemModel), - Constants.UdiEntityType.Member => _umbracoMapper.Map(relationItemModel), + Constants.ReferenceType.Document => MapDocumentReference(relationItemModel, slimEntities), + Constants.ReferenceType.Media => _umbracoMapper.Map(relationItemModel), + Constants.ReferenceType.Member => _umbracoMapper.Map(relationItemModel), + Constants.ReferenceType.DocumentTypePropertyType => _umbracoMapper.Map(relationItemModel), + Constants.ReferenceType.MediaTypePropertyType => _umbracoMapper.Map(relationItemModel), + Constants.ReferenceType.MemberTypePropertyType => _umbracoMapper.Map(relationItemModel), _ => _umbracoMapper.Map(relationItemModel), }).WhereNotNull().ToArray(); diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs index dbc51a6f41..15eb6bafd8 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs @@ -67,7 +67,7 @@ public class DocumentMapDefinition : ContentMapDefinition((source, context) => new DocumentReferenceResponseModel(), Map); mapper.Define((source, context) => new MediaReferenceResponseModel(), Map); mapper.Define((source, context) => new MemberReferenceResponseModel(), Map); + mapper.Define((source, context) => new DocumentTypePropertyTypeReferenceResponseModel(), Map); + mapper.Define((source, context) => new MediaTypePropertyTypeReferenceResponseModel(), Map); + mapper.Define((source, context) => new MemberTypePropertyTypeReferenceResponseModel(), Map); mapper.Define((source, context) => new DefaultReferenceResponseModel(), Map); mapper.Define((source, context) => new ReferenceByIdModel(), Map); mapper.Define((source, context) => new ReferenceByIdModel(), Map); @@ -25,6 +28,7 @@ public class TrackedReferenceViewModelsMapDefinition : IMapDefinition target.Published = source.NodePublished; target.DocumentType = new TrackedReferenceDocumentType { + Id = source.ContentTypeKey, Alias = source.ContentTypeAlias, Icon = source.ContentTypeIcon, Name = source.ContentTypeName, @@ -38,6 +42,7 @@ public class TrackedReferenceViewModelsMapDefinition : IMapDefinition target.Name = source.NodeName; target.MediaType = new TrackedReferenceMediaType { + Id = source.ContentTypeKey, Alias = source.ContentTypeAlias, Icon = source.ContentTypeIcon, Name = source.ContentTypeName, @@ -51,6 +56,52 @@ public class TrackedReferenceViewModelsMapDefinition : IMapDefinition target.Name = source.NodeName; target.MemberType = new TrackedReferenceMemberType { + Id = source.ContentTypeKey, + Alias = source.ContentTypeAlias, + Icon = source.ContentTypeIcon, + Name = source.ContentTypeName, + }; + } + + // Umbraco.Code.MapAll + private void Map(RelationItemModel source, DocumentTypePropertyTypeReferenceResponseModel target, MapperContext context) + { + target.Id = source.NodeKey; + target.Name = source.NodeName; + target.Alias = source.NodeAlias; + target.DocumentType = new TrackedReferenceDocumentType + { + Id = source.ContentTypeKey, + Alias = source.ContentTypeAlias, + Icon = source.ContentTypeIcon, + Name = source.ContentTypeName, + }; + } + + // Umbraco.Code.MapAll + private void Map(RelationItemModel source, MediaTypePropertyTypeReferenceResponseModel target, MapperContext context) + { + target.Id = source.NodeKey; + target.Name = source.NodeName; + target.Alias = source.NodeAlias; + target.MediaType = new TrackedReferenceMediaType + { + Id = source.ContentTypeKey, + Alias = source.ContentTypeAlias, + Icon = source.ContentTypeIcon, + Name = source.ContentTypeName, + }; + } + + // Umbraco.Code.MapAll + private void Map(RelationItemModel source, MemberTypePropertyTypeReferenceResponseModel target, MapperContext context) + { + target.Id = source.NodeKey; + target.Name = source.NodeName; + target.Alias = source.NodeAlias; + target.MemberType = new TrackedReferenceMemberType + { + Id = source.ContentTypeKey, Alias = source.ContentTypeAlias, Icon = source.ContentTypeIcon, Name = source.ContentTypeName, diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 7928c27617..e297bfc9cd 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -816,6 +816,70 @@ ] } }, + "/umbraco/management/api/v1/data-type/{id}/referenced-by": { + "get": { + "tags": [ + "Data Type" + ], + "operationId": "GetDataTypeByIdReferencedBy", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedIReferenceResponseModel" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/data-type/{id}/references": { "get": { "tags": [ @@ -872,6 +936,7 @@ "description": "The authenticated user does not have access to this resource" } }, + "deprecated": true, "security": [ { "Backoffice User": [ ] @@ -8902,6 +8967,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PublishWithDescendantsResultModel" + } + ] + } + } } }, "400": { @@ -8982,6 +9058,89 @@ ] } }, + "/umbraco/management/api/v1/document/{id}/publish-with-descendants/result/{taskId}": { + "get": { + "tags": [ + "Document" + ], + "operationId": "GetDocumentByIdPublishWithDescendantsResultByTaskId", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "taskId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PublishWithDescendantsResultModel" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/document/{id}/published": { "get": { "tags": [ @@ -10468,6 +10627,61 @@ ] } }, + "/umbraco/management/api/v1/recycle-bin/document/referenced-by": { + "get": { + "tags": [ + "Document" + ], + "operationId": "GetRecycleBinDocumentReferencedBy", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedIReferenceResponseModel" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/recycle-bin/document/root": { "get": { "tags": [ @@ -17620,6 +17834,61 @@ ] } }, + "/umbraco/management/api/v1/recycle-bin/media/referenced-by": { + "get": { + "tags": [ + "Media" + ], + "operationId": "GetRecycleBinMediaReferencedBy", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedIReferenceResponseModel" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/recycle-bin/media/root": { "get": { "tags": [ @@ -36885,6 +37154,7 @@ }, "DocumentCollectionResponseModel": { "required": [ + "ancestors", "documentType", "id", "isProtected", @@ -36940,6 +37210,16 @@ "isProtected": { "type": "boolean" }, + "ancestors": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + }, "updater": { "type": "string", "nullable": true @@ -37244,6 +37524,7 @@ }, "DocumentTreeItemResponseModel": { "required": [ + "ancestors", "createDate", "documentType", "hasChildren", @@ -37283,6 +37564,16 @@ "isProtected": { "type": "boolean" }, + "ancestors": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + }, "documentType": { "oneOf": [ { @@ -37528,6 +37819,45 @@ }, "additionalProperties": false }, + "DocumentTypePropertyTypeReferenceResponseModel": { + "required": [ + "$type", + "documentType", + "id" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "nullable": true + }, + "alias": { + "type": "string", + "nullable": true + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrackedReferenceDocumentTypeModel" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTypePropertyTypeReferenceResponseModel": "#/components/schemas/DocumentTypePropertyTypeReferenceResponseModel" + } + } + }, "DocumentTypePropertyTypeResponseModel": { "required": [ "alias", @@ -39583,6 +39913,45 @@ }, "additionalProperties": false }, + "MediaTypePropertyTypeReferenceResponseModel": { + "required": [ + "$type", + "id", + "mediaType" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "nullable": true + }, + "alias": { + "type": "string", + "nullable": true + }, + "mediaType": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrackedReferenceMediaTypeModel" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "MediaTypePropertyTypeReferenceResponseModel": "#/components/schemas/MediaTypePropertyTypeReferenceResponseModel" + } + } + }, "MediaTypePropertyTypeResponseModel": { "required": [ "alias", @@ -40348,6 +40717,45 @@ }, "additionalProperties": false }, + "MemberTypePropertyTypeReferenceResponseModel": { + "required": [ + "$type", + "id", + "memberType" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "nullable": true + }, + "alias": { + "type": "string", + "nullable": true + }, + "memberType": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrackedReferenceMemberTypeModel" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "MemberTypePropertyTypeReferenceResponseModel": "#/components/schemas/MemberTypePropertyTypeReferenceResponseModel" + } + } + }, "MemberTypePropertyTypeResponseModel": { "required": [ "alias", @@ -41520,11 +41928,20 @@ { "$ref": "#/components/schemas/DocumentReferenceResponseModel" }, + { + "$ref": "#/components/schemas/DocumentTypePropertyTypeReferenceResponseModel" + }, { "$ref": "#/components/schemas/MediaReferenceResponseModel" }, + { + "$ref": "#/components/schemas/MediaTypePropertyTypeReferenceResponseModel" + }, { "$ref": "#/components/schemas/MemberReferenceResponseModel" + }, + { + "$ref": "#/components/schemas/MemberTypePropertyTypeReferenceResponseModel" } ] } @@ -42898,6 +43315,23 @@ }, "additionalProperties": false }, + "PublishWithDescendantsResultModel": { + "required": [ + "isComplete", + "taskId" + ], + "type": "object", + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "isComplete": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "PublishedDocumentResponseModel": { "required": [ "documentType", @@ -44162,8 +44596,15 @@ "additionalProperties": false }, "TrackedReferenceDocumentTypeModel": { + "required": [ + "id" + ], "type": "object", "properties": { + "id": { + "type": "string", + "format": "uuid" + }, "icon": { "type": "string", "nullable": true @@ -44180,8 +44621,15 @@ "additionalProperties": false }, "TrackedReferenceMediaTypeModel": { + "required": [ + "id" + ], "type": "object", "properties": { + "id": { + "type": "string", + "format": "uuid" + }, "icon": { "type": "string", "nullable": true @@ -44198,8 +44646,15 @@ "additionalProperties": false }, "TrackedReferenceMemberTypeModel": { + "required": [ + "id" + ], "type": "object", "properties": { + "id": { + "type": "string", + "format": "uuid" + }, "icon": { "type": "string", "nullable": true diff --git a/src/Umbraco.Cms.Api.Management/OpenApi/BackOfficeSecurityRequirementsOperationFilterBase.cs b/src/Umbraco.Cms.Api.Management/OpenApi/BackOfficeSecurityRequirementsOperationFilterBase.cs index 907b91cdac..e2ff1e609a 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi/BackOfficeSecurityRequirementsOperationFilterBase.cs +++ b/src/Umbraco.Cms.Api.Management/OpenApi/BackOfficeSecurityRequirementsOperationFilterBase.cs @@ -38,7 +38,7 @@ public abstract class BackOfficeSecurityRequirementsOperationFilterBase : IOpera Type = ReferenceType.SecurityScheme, Id = ManagementApiConfiguration.ApiSecurityName } - }, new string[] { } + }, [] } } }; diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs index 0a00d2e963..2753bd29b8 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs @@ -16,9 +16,41 @@ public interface IUserStartNodeEntitiesService /// /// The returned entities may include entities that outside of the user start node scope, but are needed to /// for browsing to the actual user start nodes. These entities will be marked as "no access" entities. + /// + /// This method does not support pagination, because it must load all entities explicitly in order to calculate + /// the correct result, given that user start nodes can be descendants of root nodes. Consumers need to apply + /// pagination to the result if applicable. /// IEnumerable RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds); + /// + /// Calculates the applicable child entities for a given object type for users without root access. + /// + /// The object type. + /// The calculated start node paths for the user. + /// The key of the parent. + /// The number of applicable children to skip. + /// The number of applicable children to take. + /// The ordering to apply when fetching and paginating the children. + /// The total number of applicable children available. + /// A list of child entities applicable for the user. + /// + /// The returned entities may include entities that outside of the user start node scope, but are needed to + /// for browsing to the actual user start nodes. These entities will be marked as "no access" entities. + /// + IEnumerable ChildUserAccessEntities( + UmbracoObjectTypes umbracoObjectType, + string[] userStartNodePaths, + Guid parentKey, + int skip, + int take, + Ordering ordering, + out long totalItems) + { + totalItems = 0; + return []; + } + /// /// Calculates the applicable child entities from a list of candidate child entities for users without root access. /// diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs index 1a5572303b..c811d2c9c7 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs @@ -1,8 +1,12 @@ -using Umbraco.Cms.Core; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Api.Management.Models.Entities; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Scoping; using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Services.Entities; @@ -10,8 +14,24 @@ namespace Umbraco.Cms.Api.Management.Services.Entities; public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService { private readonly IEntityService _entityService; + private readonly ICoreScopeProvider _scopeProvider; + private readonly IIdKeyMap _idKeyMap; - public UserStartNodeEntitiesService(IEntityService entityService) => _entityService = entityService; + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V17.")] + public UserStartNodeEntitiesService(IEntityService entityService) + : this( + entityService, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public UserStartNodeEntitiesService(IEntityService entityService, ICoreScopeProvider scopeProvider, IIdKeyMap idKeyMap) + { + _entityService = entityService; + _scopeProvider = scopeProvider; + _idKeyMap = idKeyMap; + } /// public IEnumerable RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds) @@ -43,6 +63,54 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService .ToArray(); } + public IEnumerable ChildUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid parentKey, int skip, int take, Ordering ordering, out long totalItems) + { + Attempt parentIdAttempt = _idKeyMap.GetIdForKey(parentKey, umbracoObjectType); + if (parentIdAttempt.Success is false) + { + totalItems = 0; + return []; + } + + var parentId = parentIdAttempt.Result; + IEntitySlim? parent = _entityService.Get(parentId); + if (parent is null) + { + totalItems = 0; + return []; + } + + IEntitySlim[] children; + if (userStartNodePaths.Any(path => $"{parent.Path},".StartsWith($"{path},"))) + { + // the requested parent is one of the user start nodes (or a descendant of one), all children are by definition allowed + children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, ordering: ordering).ToArray(); + return ChildUserAccessEntities(children, userStartNodePaths); + } + + // if one or more of the user start nodes are descendants of the requested parent, find the "next child IDs" in those user start node paths + // - e.g. given the user start node path "-1,2,3,4,5", if the requested parent ID is 3, the "next child ID" is 4. + var userStartNodePathIds = userStartNodePaths.Select(path => path.Split(Constants.CharArrays.Comma).Select(int.Parse).ToArray()).ToArray(); + var allowedChildIds = userStartNodePathIds + .Where(ids => ids.Contains(parentId)) + // given the previous checks, the parent ID can never be the last in the user start node path, so this is safe + .Select(ids => ids[ids.IndexOf(parentId) + 1]) + .Distinct() + .ToArray(); + + totalItems = allowedChildIds.Length; + if (allowedChildIds.Length == 0) + { + // the requested parent is outside the scope of any user start nodes + return []; + } + + // even though we know the IDs of the allowed child entities to fetch, we still use a Query to yield correctly sorted children + IQuery query = _scopeProvider.CreateQuery().Where(x => allowedChildIds.Contains(x.Id)); + children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, query, ordering).ToArray(); + return ChildUserAccessEntities(children, userStartNodePaths); + } + /// public IEnumerable ChildUserAccessEntities(IEnumerable candidateChildren, string[] userStartNodePaths) // child entities for users without root access should include: diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs index a7c209ad2a..391714346a 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs @@ -11,5 +11,7 @@ public class DocumentCollectionResponseModel : ContentCollectionResponseModelBas public bool IsProtected { get; set; } + public IEnumerable Ancestors { get; set; } = []; + public string? Updater { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishWithDescendantsResultModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishWithDescendantsResultModel.cs new file mode 100644 index 0000000000..8bc88600b0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishWithDescendantsResultModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class PublishWithDescendantsResultModel +{ + public Guid TaskId { get; set; } + + public bool IsComplete { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/ContentTypePropertyTypeReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/ContentTypePropertyTypeReferenceResponseModel.cs new file mode 100644 index 0000000000..83dc6a1a7a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/ContentTypePropertyTypeReferenceResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; + +public abstract class ContentTypePropertyTypeReferenceResponseModel : ReferenceResponseModel +{ + public string? Alias { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/DocumentTypePropertyTypeReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/DocumentTypePropertyTypeReferenceResponseModel.cs new file mode 100644 index 0000000000..b2ef3e4a0a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/DocumentTypePropertyTypeReferenceResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; + +public class DocumentTypePropertyTypeReferenceResponseModel : ContentTypePropertyTypeReferenceResponseModel +{ + public TrackedReferenceDocumentType DocumentType { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MediaTypePropertyTypeReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MediaTypePropertyTypeReferenceResponseModel.cs new file mode 100644 index 0000000000..1baf647654 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MediaTypePropertyTypeReferenceResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; + +public class MediaTypePropertyTypeReferenceResponseModel : ContentTypePropertyTypeReferenceResponseModel +{ + public TrackedReferenceMediaType MediaType { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MemberTypePropertyTypeReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MemberTypePropertyTypeReferenceResponseModel.cs new file mode 100644 index 0000000000..199a4b0ba1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MemberTypePropertyTypeReferenceResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; + +public class MemberTypePropertyTypeReferenceResponseModel : ContentTypePropertyTypeReferenceResponseModel +{ + public TrackedReferenceMemberType MemberType { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/TrackedReferenceContentType.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/TrackedReferenceContentType.cs index 31456abada..15ac365e41 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/TrackedReferenceContentType.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/TrackedReferenceContentType.cs @@ -1,7 +1,9 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; public abstract class TrackedReferenceContentType { + public Guid Id { get; set; } + public string? Icon { get; set; } public string? Alias { get; set; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/DocumentTreeItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/DocumentTreeItemResponseModel.cs index 094522b91a..1bde763102 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/DocumentTreeItemResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/DocumentTreeItemResponseModel.cs @@ -1,4 +1,3 @@ -using Umbraco.Cms.Api.Management.ViewModels.Content; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.DocumentType; @@ -8,6 +7,8 @@ public class DocumentTreeItemResponseModel : ContentTreeItemResponseModel { public bool IsProtected { get; set; } + public IEnumerable Ancestors { get; set; } = []; + public DocumentTypeReferenceResponseModel DocumentType { get; set; } = new(); public IEnumerable Variants { get; set; } = Enumerable.Empty(); diff --git a/src/Umbraco.Core/Collections/ObservableDictionary.cs b/src/Umbraco.Core/Collections/ObservableDictionary.cs index 16683b3dfc..216b384e5f 100644 --- a/src/Umbraco.Core/Collections/ObservableDictionary.cs +++ b/src/Umbraco.Core/Collections/ObservableDictionary.cs @@ -160,9 +160,9 @@ public class ObservableDictionary : ObservableCollection, if (index != Count) { - foreach (TKey k in Indecies.Keys.Where(k => Indecies[k] >= index).ToList()) + foreach (KeyValuePair largerOrEqualToIndex in Indecies.Where(kvp => kvp.Value >= index)) { - Indecies[k]++; + Indecies[largerOrEqualToIndex.Key] = largerOrEqualToIndex.Value + 1; } } @@ -185,9 +185,9 @@ public class ObservableDictionary : ObservableCollection, Indecies.Remove(key); - foreach (TKey k in Indecies.Keys.Where(k => Indecies[k] > index).ToList()) + foreach (KeyValuePair largerThanIndex in Indecies.Where(kvp => kvp.Value > index)) { - Indecies[k]--; + Indecies[largerThanIndex.Key] = largerThanIndex.Value - 1; } } diff --git a/src/Umbraco.Core/Composing/TypeFinder.cs b/src/Umbraco.Core/Composing/TypeFinder.cs index 6728b2c7a6..2d715ba01b 100644 --- a/src/Umbraco.Core/Composing/TypeFinder.cs +++ b/src/Umbraco.Core/Composing/TypeFinder.cs @@ -233,10 +233,7 @@ public class TypeFinder : ITypeFinder excludeFromResults = new HashSet(); } - if (exclusionFilter == null) - { - exclusionFilter = new string[] { }; - } + exclusionFilter ??= []; return GetAllAssemblies() .Where(x => excludeFromResults.Contains(x) == false diff --git a/src/Umbraco.Core/Constants-ReferenceTypes.cs b/src/Umbraco.Core/Constants-ReferenceTypes.cs new file mode 100644 index 0000000000..b006a0d590 --- /dev/null +++ b/src/Umbraco.Core/Constants-ReferenceTypes.cs @@ -0,0 +1,25 @@ +namespace Umbraco.Cms.Core; + +public static partial class Constants +{ + /// + /// Defines reference types. + /// + /// + /// Reference types are used to identify the type of entity that is being referenced when exposing references + /// between Umbraco entities. + /// These are used in the management API and backoffice to indicate and warn editors when working with an entity, + /// as to what other entities depend on it. + /// These consist of references managed by Umbraco relations (e.g. document, media and member). + /// But also references that come from schema (e.g. data type usage on content types). + /// + public static class ReferenceType + { + public const string Document = UdiEntityType.Document; + public const string Media = UdiEntityType.Media; + public const string Member = UdiEntityType.Member; + public const string DocumentTypePropertyType = "document-type-property-type"; + public const string MediaTypePropertyType = "media-type-property-type"; + public const string MemberTypePropertyType = "member-type-property-type"; + } +} diff --git a/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs b/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs index 86c5e77b0a..b72820a559 100644 --- a/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs +++ b/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs @@ -34,7 +34,7 @@ public class InvalidCompositionException : Exception /// The added composition alias. /// The property type aliases. public InvalidCompositionException(string contentTypeAlias, string? addedCompositionAlias, string[] propertyTypeAliases) - : this(contentTypeAlias, addedCompositionAlias, propertyTypeAliases, new string[0]) + : this(contentTypeAlias, addedCompositionAlias, propertyTypeAliases, []) { } diff --git a/src/Umbraco.Core/Extensions/IntExtensions.cs b/src/Umbraco.Core/Extensions/IntExtensions.cs index d347993dd0..6bdb3c6435 100644 --- a/src/Umbraco.Core/Extensions/IntExtensions.cs +++ b/src/Umbraco.Core/Extensions/IntExtensions.cs @@ -27,8 +27,8 @@ public static class IntExtensions /// public static Guid ToGuid(this int value) { - var bytes = new byte[16]; - BitConverter.GetBytes(value).CopyTo(bytes, 0); + Span bytes = stackalloc byte[16]; + BitConverter.GetBytes(value).CopyTo(bytes); return new Guid(bytes); } } diff --git a/src/Umbraco.Core/Extensions/StringExtensions.cs b/src/Umbraco.Core/Extensions/StringExtensions.cs index 3d1ea4f83e..eab9d3fabf 100644 --- a/src/Umbraco.Core/Extensions/StringExtensions.cs +++ b/src/Umbraco.Core/Extensions/StringExtensions.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; @@ -57,17 +58,24 @@ public static class StringExtensions /// public static int[] GetIdsFromPathReversed(this string path) { - string[] pathSegments = path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); - List nodeIds = new(pathSegments.Length); - for (int i = pathSegments.Length - 1; i >= 0; i--) + ReadOnlySpan pathSpan = path.AsSpan(); + List nodeIds = []; + foreach (Range rangeOfPathSegment in pathSpan.Split(Constants.CharArrays.Comma)) { - if (int.TryParse(pathSegments[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) + if (int.TryParse(pathSpan[rangeOfPathSegment], NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) { nodeIds.Add(pathSegment); } } - return nodeIds.ToArray(); + var result = new int[nodeIds.Count]; + var resultIndex = 0; + for (int i = nodeIds.Count - 1; i >= 0; i--) + { + result[resultIndex++] = nodeIds[i]; + } + + return result; } /// @@ -152,14 +160,16 @@ public static class StringExtensions public static string ReplaceNonAlphanumericChars(this string input, char replacement) { - var inputArray = input.ToCharArray(); - var outputArray = new char[input.Length]; - for (var i = 0; i < inputArray.Length; i++) + var chars = input.ToCharArray(); + for (var i = 0; i < chars.Length; i++) { - outputArray[i] = char.IsLetterOrDigit(inputArray[i]) ? inputArray[i] : replacement; + if (!char.IsLetterOrDigit(chars[i])) + { + chars[i] = replacement; + } } - return new string(outputArray); + return new string(chars); } /// @@ -209,7 +219,7 @@ public static class StringExtensions var nonEmpty = queryStrings.Where(x => !x.IsNullOrWhiteSpace()).ToArray(); - if (url.Contains("?")) + if (url.Contains('?')) { return url + string.Join("&", nonEmpty).EnsureStartsWith('&'); } @@ -692,7 +702,7 @@ public static class StringExtensions if (input.Length == 0) { - return Array.Empty(); + return []; } // calc array size - must be groups of 4 @@ -807,7 +817,7 @@ public static class StringExtensions } // replace chars that would cause problems in URLs - var chArray = new char[pos]; + Span chArray = pos <= 1024 ? stackalloc char[pos] : new char[pos]; for (var i = 0; i < pos; i++) { var ch = str[i]; @@ -1293,8 +1303,7 @@ public static class StringExtensions } // most bytes from the hash are copied straight to the bytes of the new GUID (steps 5-7, 9, 11-12) - var newGuid = new byte[16]; - Array.Copy(hash, 0, newGuid, 0, 16); + Span newGuid = hash.AsSpan()[..16]; // set the four most significant bits (bits 12 through 15) of the time_hi_and_version field to the appropriate 4-bit version number from Section 4.1.3 (step 8) newGuid[6] = (byte)((newGuid[6] & 0x0F) | (version << 4)); @@ -1308,7 +1317,7 @@ public static class StringExtensions } // Converts a GUID (expressed as a byte array) to/from network order (MSB-first). - internal static void SwapByteOrder(byte[] guid) + internal static void SwapByteOrder(Span guid) { SwapBytes(guid, 0, 3); SwapBytes(guid, 1, 2); @@ -1316,12 +1325,7 @@ public static class StringExtensions SwapBytes(guid, 6, 7); } - private static void SwapBytes(byte[] guid, int left, int right) - { - var temp = guid[left]; - guid[left] = guid[right]; - guid[right] = temp; - } + private static void SwapBytes(Span guid, int left, int right) => (guid[left], guid[right]) = (guid[right], guid[left]); /// /// Checks if a given path is a full path including drive letter diff --git a/src/Umbraco.Core/GuidUtils.cs b/src/Umbraco.Core/GuidUtils.cs index 290f36cdcf..a549283845 100644 --- a/src/Umbraco.Core/GuidUtils.cs +++ b/src/Umbraco.Core/GuidUtils.cs @@ -63,7 +63,7 @@ public static class GuidUtils // a Guid is 3 blocks + 8 bits // so it turns into a 3*8+2 = 26 chars string - var chars = new char[length]; + Span chars = stackalloc char[length]; var i = 0; var j = 0; diff --git a/src/Umbraco.Core/HexEncoder.cs b/src/Umbraco.Core/HexEncoder.cs index b95376646b..b950f42c29 100644 --- a/src/Umbraco.Core/HexEncoder.cs +++ b/src/Umbraco.Core/HexEncoder.cs @@ -28,7 +28,8 @@ public static class HexEncoder public static string Encode(byte[] bytes) { var length = bytes.Length; - var chars = new char[length * 2]; + int charsLength = length * 2; + Span chars = charsLength <= 1024 ? stackalloc char[charsLength] : new char[charsLength]; var index = 0; for (var i = 0; i < length; i++) @@ -38,7 +39,7 @@ public static class HexEncoder chars[index++] = HexLutLo[byteIndex]; } - return new string(chars, 0, chars.Length); + return new string(chars); } /// @@ -54,7 +55,8 @@ public static class HexEncoder public static string Encode(byte[] bytes, char separator, int blockSize, int blockCount) { var length = bytes.Length; - var chars = new char[(length * 2) + blockCount]; + int charsLength = (length * 2) + blockCount; + Span chars = charsLength <= 1024 ? stackalloc char[charsLength] : new char[charsLength]; var count = 0; var size = 0; var index = 0; @@ -80,6 +82,6 @@ public static class HexEncoder count++; } - return new string(chars, 0, chars.Length); + return new string(chars); } } diff --git a/src/Umbraco.Core/HostedServices/IBackgroundTaskQueue.cs b/src/Umbraco.Core/HostedServices/IBackgroundTaskQueue.cs new file mode 100644 index 0000000000..0fef380e8f --- /dev/null +++ b/src/Umbraco.Core/HostedServices/IBackgroundTaskQueue.cs @@ -0,0 +1,20 @@ +namespace Umbraco.Cms.Core.HostedServices; + +/// +/// A Background Task Queue, to enqueue tasks for executing in the background. +/// +/// +/// Borrowed from https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-5.0 +/// +public interface IBackgroundTaskQueue +{ + /// + /// Enqueue a work item to be executed on in the background. + /// + void QueueBackgroundWorkItem(Func workItem); + + /// + /// Dequeue the first item on the queue. + /// + Task?> DequeueAsync(CancellationToken cancellationToken); +} diff --git a/src/Umbraco.Core/Media/TypeDetector/RasterizedTypeDetector.cs b/src/Umbraco.Core/Media/TypeDetector/RasterizedTypeDetector.cs index 6f4e7a8a86..c6793b7904 100644 --- a/src/Umbraco.Core/Media/TypeDetector/RasterizedTypeDetector.cs +++ b/src/Umbraco.Core/Media/TypeDetector/RasterizedTypeDetector.cs @@ -4,7 +4,6 @@ public abstract class RasterizedTypeDetector { public static byte[]? GetFileHeader(Stream fileStream) { - fileStream.Seek(0, SeekOrigin.Begin); var header = new byte[8]; fileStream.Seek(0, SeekOrigin.Begin); diff --git a/src/Umbraco.Core/Models/Content.cs b/src/Umbraco.Core/Models/Content.cs index ce8f769cad..880c7b6bd0 100644 --- a/src/Umbraco.Core/Models/Content.cs +++ b/src/Umbraco.Core/Models/Content.cs @@ -197,7 +197,7 @@ public class Content : ContentBase, IContent /// [IgnoreDataMember] - public IEnumerable PublishedCultures => _publishInfos?.Keys ?? Enumerable.Empty(); + public IEnumerable PublishedCultures => _publishInfos?.Keys ?? []; /// public bool IsCulturePublished(string culture) diff --git a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs index 7e09a96225..476f20b821 100644 --- a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs +++ b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Core.Models.ContentPublishing; +namespace Umbraco.Cms.Core.Models.ContentPublishing; public sealed class ContentPublishingBranchResult { @@ -7,4 +7,6 @@ public sealed class ContentPublishingBranchResult public IEnumerable SucceededItems { get; set; } = []; public IEnumerable FailedItems { get; set; } = []; + + public Guid? AcceptedTaskId { get; init; } } diff --git a/src/Umbraco.Core/Models/Membership/User.cs b/src/Umbraco.Core/Models/Membership/User.cs index b51d207aa3..487f9c4209 100644 --- a/src/Umbraco.Core/Models/Membership/User.cs +++ b/src/Umbraco.Core/Models/Membership/User.cs @@ -53,8 +53,8 @@ public class User : EntityBase, IUser, IProfile _language = globalSettings.DefaultUILanguage; _isApproved = true; _isLockedOut = false; - _startContentIds = new int[] { }; - _startMediaIds = new int[] { }; + _startContentIds = []; + _startMediaIds = []; // cannot be null _rawPasswordValue = string.Empty; @@ -101,8 +101,8 @@ public class User : EntityBase, IUser, IProfile _userGroups = new HashSet(); _isApproved = true; _isLockedOut = false; - _startContentIds = new int[] { }; - _startMediaIds = new int[] { }; + _startContentIds = []; + _startMediaIds = []; } /// diff --git a/src/Umbraco.Core/Models/Navigation/NavigationNode.cs b/src/Umbraco.Core/Models/Navigation/NavigationNode.cs index d2db0c6294..f57191b9d1 100644 --- a/src/Umbraco.Core/Models/Navigation/NavigationNode.cs +++ b/src/Umbraco.Core/Models/Navigation/NavigationNode.cs @@ -39,9 +39,6 @@ public sealed class NavigationNode child.SortOrder = _children.Count; _children.Add(childKey); - - // Update the navigation structure - navigationStructure[childKey] = child; } public void RemoveChild(ConcurrentDictionary navigationStructure, Guid childKey) @@ -53,8 +50,5 @@ public sealed class NavigationNode _children.Remove(childKey); child.Parent = null; - - // Update the navigation structure - navigationStructure[childKey] = child; } } diff --git a/src/Umbraco.Core/Models/RelationDirectionFilter.cs b/src/Umbraco.Core/Models/RelationDirectionFilter.cs new file mode 100644 index 0000000000..1a71f8e070 --- /dev/null +++ b/src/Umbraco.Core/Models/RelationDirectionFilter.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Definition of relation directions used as a filter when requesting if a given item has relations. +/// +[Flags] +public enum RelationDirectionFilter +{ + Parent = 1, + Child = 2, + Any = Parent | Child +} diff --git a/src/Umbraco.Core/Models/RelationItem.cs b/src/Umbraco.Core/Models/RelationItem.cs index a865e7cc2f..41fde0e867 100644 --- a/src/Umbraco.Core/Models/RelationItem.cs +++ b/src/Umbraco.Core/Models/RelationItem.cs @@ -23,6 +23,9 @@ public class RelationItem [DataMember(Name = "published")] public bool? NodePublished { get; set; } + [DataMember(Name = "contentTypeKey")] + public Guid ContentTypeKey { get; set; } + [DataMember(Name = "icon")] public string? ContentTypeIcon { get; set; } diff --git a/src/Umbraco.Core/Models/RelationItemModel.cs b/src/Umbraco.Core/Models/RelationItemModel.cs index a05c8f6591..1ca3bb9e11 100644 --- a/src/Umbraco.Core/Models/RelationItemModel.cs +++ b/src/Umbraco.Core/Models/RelationItemModel.cs @@ -1,15 +1,19 @@ -namespace Umbraco.Cms.Core.Models; +namespace Umbraco.Cms.Core.Models; public class RelationItemModel { public Guid NodeKey { get; set; } + public string? NodeAlias { get; set; } + public string? NodeName { get; set; } public string? NodeType { get; set; } public bool? NodePublished { get; set; } + public Guid ContentTypeKey { get; set; } + public string? ContentTypeIcon { get; set; } public string? ContentTypeAlias { get; set; } diff --git a/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs index ad113533d8..f0babf61f3 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs @@ -24,6 +24,5 @@ public interface IDataTypeRepository : IReadWriteQueryRepository /// /// /// - IReadOnlyDictionary> FindListViewUsages(int id) => throw new NotImplementedException(); } diff --git a/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs index c437d1df82..5a730bb536 100644 --- a/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs @@ -24,6 +24,29 @@ public interface ITrackedReferencesRepository bool filterMustBeIsDependency, out long totalRecords); + /// + /// Gets a paged result of items which are in relation with an item in the recycle bin. + /// + /// The Umbraco object type that has recycle bin support (currently Document or Media). + /// The amount of items to skip. + /// The amount of items to take. + /// + /// A boolean indicating whether to filter only the RelationTypes which are + /// dependencies (isDependency field is set to true). + /// + /// The total count of the items with reference to the current item. + /// An enumerable list of objects. + IEnumerable GetPagedRelationsForRecycleBin( + Guid objectTypeKey, + long skip, + long take, + bool filterMustBeIsDependency, + out long totalRecords) + { + totalRecords = 0; + return []; + } + /// /// Gets a page of items used in any kind of relation from selected integer ids. /// diff --git a/src/Umbraco.Core/Routing/ContentFinderByRedirectUrl.cs b/src/Umbraco.Core/Routing/ContentFinderByRedirectUrl.cs index c6f8a49fcd..adc367a97f 100644 --- a/src/Umbraco.Core/Routing/ContentFinderByRedirectUrl.cs +++ b/src/Umbraco.Core/Routing/ContentFinderByRedirectUrl.cs @@ -53,21 +53,38 @@ public class ContentFinderByRedirectUrl : IContentFinder return false; } - var route = frequest.Domain != null - ? frequest.Domain.ContentId + - DomainUtilities.PathRelativeToDomain(frequest.Domain.Uri, frequest.AbsolutePathDecoded) - : frequest.AbsolutePathDecoded; - + var route = frequest.AbsolutePathDecoded; IRedirectUrl? redirectUrl = await _redirectUrlService.GetMostRecentRedirectUrlAsync(route, frequest.Culture); - if (redirectUrl == null) + if (redirectUrl is null) { if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("No match for route: {Route}", route); } - return false; + // Routes under domains can be stored with the integer ID of the content where the domains were defined as the first part of the route, + // so if we haven't found a redirect, try using that format too. + // See: https://github.com/umbraco/Umbraco-CMS/pull/18160 and https://github.com/umbraco/Umbraco-CMS/pull/18763 + if (frequest.Domain is not null) + { + route = frequest.Domain.ContentId + DomainUtilities.PathRelativeToDomain(frequest.Domain.Uri, frequest.AbsolutePathDecoded); + redirectUrl = await _redirectUrlService.GetMostRecentRedirectUrlAsync(route, frequest.Culture); + + if (redirectUrl is null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("No match for route with domain: {Route}", route); + } + + return false; + } + } + else + { + return false; + } } IPublishedContent? content = umbracoContext.Content?.GetById(redirectUrl.ContentId); diff --git a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs index 314f2c5598..c3e779b0d7 100644 --- a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs @@ -249,7 +249,8 @@ public class NewDefaultUrlProvider : IUrlProvider culture); var defaultCulture = _localizationService.GetDefaultLanguageIsoCode(); - if (domainUri is not null || string.IsNullOrEmpty(culture) || + if (domainUri is not null || + string.IsNullOrEmpty(culture) || culture.Equals(defaultCulture, StringComparison.InvariantCultureIgnoreCase)) { var url = AssembleUrl(domainUri, path, current, mode).ToString(); diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index 2b81027cd2..bd32e3d04a 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -64,16 +64,50 @@ internal sealed class ContentEditingService return Task.FromResult(content); } + [Obsolete("Please use the validate update method that is not obsoleted. Scheduled for removal in V17.")] public async Task> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel) + => await ValidateUpdateAsync(key, updateModel, Guid.Empty); + + public async Task> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel, Guid userKey) { IContent? content = ContentService.GetById(key); return content is not null - ? await ValidateCulturesAndPropertiesAsync(updateModel, content.ContentType.Key, updateModel.Cultures) + ? await ValidateCulturesAndPropertiesAsync(updateModel, content.ContentType.Key, await GetCulturesToValidate(updateModel.Cultures, userKey)) : Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentValidationResult()); } + [Obsolete("Please use the validate create method that is not obsoleted. Scheduled for removal in V17.")] public async Task> ValidateCreateAsync(ContentCreateModel createModel) - => await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, createModel.Variants.Select(variant => variant.Culture)); + => await ValidateCreateAsync(createModel, Guid.Empty); + + public async Task> ValidateCreateAsync(ContentCreateModel createModel, Guid userKey) + => await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey)); + + private async Task?> GetCulturesToValidate(IEnumerable? cultures, Guid userKey) + { + // Cultures to validate can be provided by the calling code, but if the editor is restricted to only have + // access to certain languages, we don't want to validate by any they aren't allowed to edit. + + // TODO: Remove this check once the obsolete overloads to ValidateCreateAsync and ValidateUpdateAsync that don't provide a user key are removed. + // We only have this to ensure backwards compatibility with the obsolete overloads. + if (userKey == Guid.Empty) + { + return cultures; + } + + HashSet? allowedCultures = await GetAllowedCulturesForEditingUser(userKey); + + if (cultures == null) + { + // If no cultures are provided, we are asking to validate all cultures. But if the user doesn't have access to all, we + // should only validate the ones they do. + var allCultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode).ToList(); + return allowedCultures.Count == allCultures.Count ? null : allowedCultures; + } + + // If explicit cultures are provided, we should only validate the ones the user has access to. + return cultures.Where(x => !string.IsNullOrEmpty(x) && allowedCultures.Contains(x)).ToList(); + } public async Task> CreateAsync(ContentCreateModel createModel, Guid userKey) { @@ -118,16 +152,7 @@ internal sealed class ContentEditingService IContent? existingContent = await GetAsync(contentWithPotentialUnallowedChanges.Key); - IUser? user = await _userService.GetAsync(userKey); - - if (user is null) - { - return contentWithPotentialUnallowedChanges; - } - - var allowedLanguageIds = user.CalculateAllowedLanguageIds(_localizationService)!; - - var allowedCultures = (await _languageService.GetIsoCodesByIdsAsync(allowedLanguageIds)).ToHashSet(); + HashSet? allowedCultures = await GetAllowedCulturesForEditingUser(userKey); ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); @@ -202,6 +227,16 @@ internal sealed class ContentEditingService return contentWithPotentialUnallowedChanges; } + private async Task> GetAllowedCulturesForEditingUser(Guid userKey) + { + IUser? user = await _userService.GetAsync(userKey) + ?? throw new InvalidOperationException($"Could not find user by key {userKey} when editing or validating content."); + + var allowedLanguageIds = user.CalculateAllowedLanguageIds(_localizationService)!; + + return (await _languageService.GetIsoCodesByIdsAsync(allowedLanguageIds)).ToHashSet(); + } + public async Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey) { IContent? content = ContentService.GetById(key); diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs index 3c038cd33b..92f596e1e7 100644 --- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs @@ -198,7 +198,7 @@ internal abstract class ContentEditingServiceBase(status, content); } - if (disabledWhenReferenced && _relationService.IsRelated(content.Id)) + if (disabledWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) { return Attempt.FailWithStatus(referenceFailStatus, content); } diff --git a/src/Umbraco.Core/Services/ContentPublishingService.cs b/src/Umbraco.Core/Services/ContentPublishingService.cs index 4b81f8df49..5f72c36863 100644 --- a/src/Umbraco.Core/Services/ContentPublishingService.cs +++ b/src/Umbraco.Core/Services/ContentPublishingService.cs @@ -1,6 +1,9 @@ +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentPublishing; @@ -12,6 +15,9 @@ namespace Umbraco.Cms.Core.Services; internal sealed class ContentPublishingService : IContentPublishingService { + private const string IsPublishingBranchRuntimeCacheKeyPrefix = "temp_indexing_op_"; + private const string PublishingBranchResultCacheKeyPrefix = "temp_indexing_result_"; + private readonly ICoreScopeProvider _coreScopeProvider; private readonly IContentService _contentService; private readonly IUserIdKeyResolver _userIdKeyResolver; @@ -20,6 +26,9 @@ internal sealed class ContentPublishingService : IContentPublishingService private readonly ILanguageService _languageService; private ContentSettings _contentSettings; private readonly IRelationService _relationService; + private readonly ILogger _logger; + private readonly IBackgroundTaskQueue _backgroundTaskQueue; + private readonly IAppPolicyCache _runtimeCache; public ContentPublishingService( ICoreScopeProvider coreScopeProvider, @@ -29,7 +38,10 @@ internal sealed class ContentPublishingService : IContentPublishingService IContentTypeService contentTypeService, ILanguageService languageService, IOptionsMonitor optionsMonitor, - IRelationService relationService) + IRelationService relationService, + ILogger logger, + IBackgroundTaskQueue backgroundTaskQueue, + IAppPolicyCache runtimeCache) { _coreScopeProvider = coreScopeProvider; _contentService = contentService; @@ -38,6 +50,9 @@ internal sealed class ContentPublishingService : IContentPublishingService _contentTypeService = contentTypeService; _languageService = languageService; _relationService = relationService; + _logger = logger; + _backgroundTaskQueue = backgroundTaskQueue; + _runtimeCache = runtimeCache; _contentSettings = optionsMonitor.CurrentValue; optionsMonitor.OnChange((contentSettings) => { @@ -251,54 +266,132 @@ internal sealed class ContentPublishingService : IContentPublishingService } /// - [Obsolete("This method is not longer used as the 'force' parameter has been split into publishing unpublished and force re-published. Please use the overload containing parameters for those options instead. Will be removed in V17.")] + [Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead. Scheduled for removal in Umbraco 17.")] public async Task> PublishBranchAsync(Guid key, IEnumerable cultures, bool force, Guid userKey) => await PublishBranchAsync(key, cultures, force ? PublishBranchFilter.IncludeUnpublished : PublishBranchFilter.Default, userKey); /// + [Obsolete("Please use the overload containing all parameters. Scheduled for removal in Umbraco 17.")] public async Task> PublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey) + => await PublishBranchAsync(key, cultures, publishBranchFilter, userKey, false); + + /// + public async Task> PublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey, bool useBackgroundThread) { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - IContent? content = _contentService.GetById(key); - if (content is null) + if (useBackgroundThread) { - return Attempt.FailWithStatus( - ContentPublishingOperationStatus.ContentNotFound, - new ContentPublishingBranchResult + _logger.LogInformation("Starting async background thread for publishing branch."); + + var taskId = Guid.NewGuid(); + _backgroundTaskQueue.QueueBackgroundWorkItem( + cancellationToken => { - FailedItems = new[] + using (ExecutionContext.SuppressFlow()) { - new ContentPublishingBranchItemResult - { - Key = key, OperationStatus = ContentPublishingOperationStatus.ContentNotFound - } + Task.Run(async () => await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey, taskId) ); + return Task.CompletedTask; } }); + + return Attempt.SucceedWithStatus(ContentPublishingOperationStatus.Accepted, new ContentPublishingBranchResult { AcceptedTaskId = taskId}); } - - var userId = await _userIdKeyResolver.GetAsync(userKey); - IEnumerable result = _contentService.PublishBranch(content, publishBranchFilter, cultures.ToArray(), userId); - scope.Complete(); - - var itemResults = result.ToDictionary(r => r.Content.Key, ToContentPublishingOperationStatus); - var branchResult = new ContentPublishingBranchResult + else { - Content = content, - SucceededItems = itemResults - .Where(i => i.Value is ContentPublishingOperationStatus.Success) - .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) - .ToArray(), - FailedItems = itemResults - .Where(i => i.Value is not ContentPublishingOperationStatus.Success) - .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) - .ToArray() - }; - - return branchResult.FailedItems.Any() is false - ? Attempt.SucceedWithStatus(ContentPublishingOperationStatus.Success, branchResult) - : Attempt.FailWithStatus(ContentPublishingOperationStatus.FailedBranch, branchResult); + return await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey); + } } + private async Task> PerformPublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey, Guid? taskId = null) + { + try + { + if (taskId.HasValue) + { + SetIsPublishingBranch(taskId.Value); + } + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + IContent? content = _contentService.GetById(key); + if (content is null) + { + return Attempt.FailWithStatus( + ContentPublishingOperationStatus.ContentNotFound, + new ContentPublishingBranchResult + { + FailedItems = new[] + { + new ContentPublishingBranchItemResult + { + Key = key, + OperationStatus = ContentPublishingOperationStatus.ContentNotFound, + } + } + }); + } + + var userId = await _userIdKeyResolver.GetAsync(userKey); + IEnumerable result = _contentService.PublishBranch(content, publishBranchFilter, cultures.ToArray(), userId); + scope.Complete(); + + var itemResults = result.ToDictionary(r => r.Content.Key, ToContentPublishingOperationStatus); + var branchResult = new ContentPublishingBranchResult + { + Content = content, + SucceededItems = itemResults + .Where(i => i.Value is ContentPublishingOperationStatus.Success) + .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) + .ToArray(), + FailedItems = itemResults + .Where(i => i.Value is not ContentPublishingOperationStatus.Success) + .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) + .ToArray() + }; + + Attempt attempt = branchResult.FailedItems.Any() is false + ? Attempt.SucceedWithStatus(ContentPublishingOperationStatus.Success, branchResult) + : Attempt.FailWithStatus(ContentPublishingOperationStatus.FailedBranch, branchResult); + if (taskId.HasValue) + { + SetPublishingBranchResult(taskId.Value, attempt); + } + + return attempt; + } + finally + { + if (taskId.HasValue) + { + ClearIsPublishingBranch(taskId.Value); + } + } + } + + /// + public Task IsPublishingBranchAsync(Guid taskId) => Task.FromResult(_runtimeCache.Get(GetIsPublishingBranchCacheKey(taskId)) is not null); + + /// + public Task> GetPublishBranchResultAsync(Guid taskId) + { + var taskResult = _runtimeCache.Get(GetPublishingBranchResultCacheKey(taskId)) as Attempt?; + if (taskResult is null) + { + return Task.FromResult(Attempt.FailWithStatus(ContentPublishingOperationStatus.TaskResultNotFound, new ContentPublishingBranchResult())); + } + + // We won't clear the cache here just in case we remove references to the returned object. It expires after 60 seconds anyway. + return Task.FromResult(taskResult.Value); + } + + private void SetIsPublishingBranch(Guid taskId) => _runtimeCache.Insert(GetIsPublishingBranchCacheKey(taskId), () => "tempValue", TimeSpan.FromMinutes(10)); + + private void ClearIsPublishingBranch(Guid taskId) => _runtimeCache.Clear(GetIsPublishingBranchCacheKey(taskId)); + + private static string GetIsPublishingBranchCacheKey(Guid taskId) => IsPublishingBranchRuntimeCacheKeyPrefix + taskId; + + private void SetPublishingBranchResult(Guid taskId, Attempt result) + => _runtimeCache.Insert(GetPublishingBranchResultCacheKey(taskId), () => result, TimeSpan.FromMinutes(1)); + + private static string GetPublishingBranchResultCacheKey(Guid taskId) => PublishingBranchResultCacheKeyPrefix + taskId; /// public async Task> UnpublishAsync(Guid key, ISet? cultures, Guid userKey) @@ -311,7 +404,7 @@ internal sealed class ContentPublishingService : IContentPublishingService return Attempt.Fail(ContentPublishingOperationStatus.ContentNotFound); } - if (_contentSettings.DisableUnpublishWhenReferenced && _relationService.IsRelated(content.Id)) + if (_contentSettings.DisableUnpublishWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) { scope.Complete(); return Attempt.Fail(ContentPublishingOperationStatus.CannotUnpublishWhenReferenced); diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 75e26358a0..287b3057c1 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -2637,7 +2637,7 @@ public class ContentService : RepositoryService, IContentService { foreach (IContent content in contents) { - if (_contentSettings.DisableDeleteWhenReferenced && _relationService.IsRelated(content.Id)) + if (_contentSettings.DisableDeleteWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) { continue; } @@ -3247,7 +3247,7 @@ public class ContentService : RepositoryService, IContentService content.Name, content.Id, culture, - "document is culture awaiting release"); + "document has culture awaiting release"); } return new PublishResult( diff --git a/src/Umbraco.Core/Services/DataTypeService.cs b/src/Umbraco.Core/Services/DataTypeService.cs index 71515b28b2..83effe7400 100644 --- a/src/Umbraco.Core/Services/DataTypeService.cs +++ b/src/Umbraco.Core/Services/DataTypeService.cs @@ -25,12 +25,15 @@ namespace Umbraco.Cms.Core.Services.Implement private readonly IDataTypeRepository _dataTypeRepository; private readonly IDataTypeContainerRepository _dataTypeContainerRepository; private readonly IContentTypeRepository _contentTypeRepository; + private readonly IMediaTypeRepository _mediaTypeRepository; + private readonly IMemberTypeRepository _memberTypeRepository; private readonly IAuditRepository _auditRepository; private readonly IIOHelper _ioHelper; private readonly IDataTypeContainerService _dataTypeContainerService; private readonly IUserIdKeyResolver _userIdKeyResolver; private readonly Lazy _idKeyMap; + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")] public DataTypeService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -41,12 +44,41 @@ namespace Umbraco.Cms.Core.Services.Implement IContentTypeRepository contentTypeRepository, IIOHelper ioHelper, Lazy idKeyMap) + : this( + provider, + loggerFactory, + eventMessagesFactory, + dataTypeRepository, + dataValueEditorFactory, + auditRepository, + contentTypeRepository, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + ioHelper, + idKeyMap) + { + } + + public DataTypeService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IDataTypeRepository dataTypeRepository, + IDataValueEditorFactory dataValueEditorFactory, + IAuditRepository auditRepository, + IContentTypeRepository contentTypeRepository, + IMediaTypeRepository mediaTypeRepository, + IMemberTypeRepository memberTypeRepository, + IIOHelper ioHelper, + Lazy idKeyMap) : base(provider, loggerFactory, eventMessagesFactory) { _dataValueEditorFactory = dataValueEditorFactory; _dataTypeRepository = dataTypeRepository; _auditRepository = auditRepository; _contentTypeRepository = contentTypeRepository; + _mediaTypeRepository = mediaTypeRepository; + _memberTypeRepository = memberTypeRepository; _ioHelper = ioHelper; _idKeyMap = idKeyMap; @@ -62,7 +94,7 @@ namespace Umbraco.Cms.Core.Services.Implement public Attempt?> CreateContainer(int parentId, Guid key, string name, int userId = Constants.Security.SuperUserId) { EventMessages evtMsgs = EventMessagesFactory.Get(); - using (ScopeProvider.CreateCoreScope(autoComplete:true)) + using (ScopeProvider.CreateCoreScope(autoComplete: true)) { try { @@ -129,7 +161,7 @@ namespace Umbraco.Cms.Core.Services.Implement public Attempt SaveContainer(EntityContainer container, int userId = Constants.Security.SuperUserId) { EventMessages evtMsgs = EventMessagesFactory.Get(); - using (ScopeProvider.CreateCoreScope(autoComplete:true)) + using (ScopeProvider.CreateCoreScope(autoComplete: true)) { var isNew = container.Id == 0; Guid? parentKey = isNew && container.ParentId > 0 ? _dataTypeContainerRepository.Get(container.ParentId)?.Key : null; @@ -155,7 +187,7 @@ namespace Umbraco.Cms.Core.Services.Implement public Attempt DeleteContainer(int containerId, int userId = Constants.Security.SuperUserId) { EventMessages evtMsgs = EventMessagesFactory.Get(); - using (ScopeProvider.CreateCoreScope(autoComplete:true)) + using (ScopeProvider.CreateCoreScope(autoComplete: true)) { EntityContainer? container = _dataTypeContainerRepository.Get(containerId); if (container == null) @@ -180,7 +212,7 @@ namespace Umbraco.Cms.Core.Services.Implement public Attempt?> RenameContainer(int id, string name, int userId = Constants.Security.SuperUserId) { EventMessages evtMsgs = EventMessagesFactory.Get(); - using (ScopeProvider.CreateCoreScope(autoComplete:true)) + using (ScopeProvider.CreateCoreScope(autoComplete: true)) { try { @@ -479,7 +511,7 @@ namespace Umbraco.Cms.Core.Services.Implement DataTypeOperationStatus.Success => OperationResult.Attempt.Succeed(MoveOperationStatusType.Success, evtMsgs, result.Result), DataTypeOperationStatus.CancelledByNotification => OperationResult.Attempt.Fail(MoveOperationStatusType.FailedCancelledByEvent, evtMsgs, result.Result), DataTypeOperationStatus.ParentNotFound => OperationResult.Attempt.Fail(MoveOperationStatusType.FailedParentNotFound, evtMsgs, result.Result), - _ => OperationResult.Attempt.Fail(MoveOperationStatusType.FailedNotAllowedByPath, evtMsgs, result.Result, new InvalidOperationException($"Invalid operation status: {result.Status}")), + _ => OperationResult.Attempt.Fail(MoveOperationStatusType.FailedNotAllowedByPath, evtMsgs, result.Result, new InvalidOperationException($"Invalid operation status: {result.Status}")), }; } @@ -684,7 +716,7 @@ namespace Umbraco.Cms.Core.Services.Implement /// public Task>, DataTypeOperationStatus>> GetReferencesAsync(Guid id) { - using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete:true); + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); IDataType? dataType = GetDataTypeFromRepository(id); if (dataType == null) { @@ -695,12 +727,119 @@ namespace Umbraco.Cms.Core.Services.Implement return Task.FromResult(Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, usages)); } + /// public IReadOnlyDictionary> GetListViewReferences(int id) { using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); return _dataTypeRepository.FindListViewUsages(id); } + /// + public Task> GetPagedRelationsAsync(Guid key, int skip, int take) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + IDataType? dataType = GetDataTypeFromRepository(key); + if (dataType == null) + { + // Is an unexpected response, but returning an empty collection aligns with how we handle retrieval of concrete Umbraco + // relations based on documents, media and members. + return Task.FromResult(new PagedModel()); + } + + // We don't really need true paging here, as the number of data type relations will be small compared to what there could + // potentially by for concrete Umbraco relations based on documents, media and members. + // So we'll retrieve all usages for the data type and construct a paged response. + // This allows us to re-use the existing repository methods used for FindUsages and FindListViewUsages. + IReadOnlyDictionary> usages = _dataTypeRepository.FindUsages(dataType.Id); + IReadOnlyDictionary> listViewUsages = _dataTypeRepository.FindListViewUsages(dataType.Id); + + // Combine the property and list view usages into a single collection of property aliases and content type UDIs. + IList<(string PropertyAlias, Udi Udi)> combinedUsages = usages + .SelectMany(kvp => kvp.Value.Select(value => (value, kvp.Key))) + .Concat(listViewUsages.SelectMany(kvp => kvp.Value.Select(value => (value, kvp.Key)))) + .ToList(); + + var totalItems = combinedUsages.Count; + + // Create the page of items. + IList<(string PropertyAlias, Udi Udi)> pagedUsages = combinedUsages + .OrderBy(x => x.Udi.EntityType) // Document types first, then media types, then member types. + .ThenBy(x => x.PropertyAlias) + .Skip(skip) + .Take(take) + .ToList(); + + // Get the content types for the UDIs referenced in the page of items to construct the response from. + // They could be document, media or member types. + IList contentTypes = GetReferencedContentTypes(pagedUsages); + + IEnumerable relations = pagedUsages + .Select(x => + { + // Get the matching content type so we can populate the content type and property details. + IContentTypeComposition contentType = contentTypes.Single(y => y.Key == ((GuidUdi)x.Udi).Guid); + + string nodeType = x.Udi.EntityType switch + { + Constants.UdiEntityType.DocumentType => Constants.ReferenceType.DocumentTypePropertyType, + Constants.UdiEntityType.MediaType => Constants.ReferenceType.MediaTypePropertyType, + Constants.UdiEntityType.MemberType => Constants.ReferenceType.MemberTypePropertyType, + _ => throw new ArgumentOutOfRangeException(nameof(x.Udi.EntityType)), + }; + + // Look-up the property details from the property alias. This will be null for a list view reference. + IPropertyType? propertyType = contentType.PropertyTypes.SingleOrDefault(y => y.Alias == x.PropertyAlias); + return new RelationItemModel + { + ContentTypeKey = contentType.Key, + ContentTypeAlias = contentType.Alias, + ContentTypeIcon = contentType.Icon, + ContentTypeName = contentType.Name, + NodeType = nodeType, + NodeName = propertyType?.Name ?? x.PropertyAlias, + NodeAlias = x.PropertyAlias, + NodeKey = propertyType?.Key ?? Guid.Empty, + }; + }); + + var pagedModel = new PagedModel(totalItems, relations); + return Task.FromResult(pagedModel); + } + + private IList GetReferencedContentTypes(IList<(string PropertyAlias, Udi Udi)> pagedUsages) + { + IEnumerable documentTypes = GetContentTypes( + pagedUsages, + Constants.UdiEntityType.DocumentType, + _contentTypeRepository); + IEnumerable mediaTypes = GetContentTypes( + pagedUsages, + Constants.UdiEntityType.MediaType, + _mediaTypeRepository); + IEnumerable memberTypes = GetContentTypes( + pagedUsages, + Constants.UdiEntityType.MemberType, + _memberTypeRepository); + return documentTypes.Concat(mediaTypes).Concat(memberTypes).ToList(); + } + + private static IEnumerable GetContentTypes( + IEnumerable<(string PropertyAlias, Udi Udi)> dataTypeUsages, + string entityType, + IContentTypeRepositoryBase repository) + where T : IContentTypeComposition + { + Guid[] contentTypeKeys = dataTypeUsages + .Where(x => x.Udi is GuidUdi && x.Udi.EntityType == entityType) + .Select(x => ((GuidUdi)x.Udi).Guid) + .Distinct() + .ToArray(); + return contentTypeKeys.Length > 0 + ? repository.GetMany(contentTypeKeys) + : []; + } + /// public IEnumerable ValidateConfigurationData(IDataType dataType) { diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index 7f2ba473b7..f2fe2eefd8 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Linq.Expressions; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; @@ -758,5 +759,26 @@ public class EntityService : RepositoryService, IEntityService return _entityRepository.GetPagedResultsByQuery(query, objectTypeGuids, pageNumber, pageSize, out totalRecords, filter, ordering); } } -} + /// > + public Guid[] GetPathKeys(ITreeEntity entity, bool omitSelf = false) + { + IEnumerable ids = entity.Path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries) + .Select(x => int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val) ? val : -1) + .Where(x => x != -1); + + Guid[] keys = ids + .Select(x => _idKeyMap.GetKeyForId(x, UmbracoObjectTypes.Document)) + .Where(x => x.Success) + .Select(x => x.Result) + .ToArray(); + + if (omitSelf) + { + // Omit the last path key as that will be for the item itself. + return keys.Take(keys.Length - 1).ToArray(); + } + + return keys; + } +} diff --git a/src/Umbraco.Core/Services/IContentEditingService.cs b/src/Umbraco.Core/Services/IContentEditingService.cs index 18f8777bb8..be8325de81 100644 --- a/src/Umbraco.Core/Services/IContentEditingService.cs +++ b/src/Umbraco.Core/Services/IContentEditingService.cs @@ -8,10 +8,22 @@ public interface IContentEditingService { Task GetAsync(Guid key); + [Obsolete("Please use the validate create method that is not obsoleted. Scheduled for removal in Umbraco 17.")] Task> ValidateCreateAsync(ContentCreateModel createModel); + Task> ValidateCreateAsync(ContentCreateModel createModel, Guid userKey) +#pragma warning disable CS0618 // Type or member is obsolete + => ValidateCreateAsync(createModel); +#pragma warning restore CS0618 // Type or member is obsolete + + [Obsolete("Please use the validate update method that is not obsoleted. Scheduled for removal in Umbraco 17.")] Task> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel); + Task> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel, Guid userKey) +#pragma warning disable CS0618 // Type or member is obsolete + => ValidateUpdateAsync(key, updateModel); +#pragma warning restore CS0618 // Type or member is obsolete + Task> CreateAsync(ContentCreateModel createModel, Guid userKey); Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey); diff --git a/src/Umbraco.Core/Services/IContentPublishingService.cs b/src/Umbraco.Core/Services/IContentPublishingService.cs index bf41028977..f2d7edef2c 100644 --- a/src/Umbraco.Core/Services/IContentPublishingService.cs +++ b/src/Umbraco.Core/Services/IContentPublishingService.cs @@ -27,7 +27,7 @@ public interface IContentPublishingService /// A value indicating whether to force-publish content that is not already published. /// The identifier of the user performing the operation. /// Result of the publish operation. - [Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead. Will be removed in V17.")] + [Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead. Scheduled for removal in Umbraco 17.")] Task> PublishBranchAsync(Guid key, IEnumerable cultures, bool force, Guid userKey); /// @@ -38,11 +38,40 @@ public interface IContentPublishingService /// A value indicating options for force publishing unpublished or re-publishing unchanged content. /// The identifier of the user performing the operation. /// Result of the publish operation. + [Obsolete("Please use the overload containing all parameters. Scheduled for removal in Umbraco 17.")] Task> PublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey) #pragma warning disable CS0618 // Type or member is obsolete => PublishBranchAsync(key, cultures, publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished), userKey); #pragma warning restore CS0618 // Type or member is obsolete + /// + /// Publishes a content branch. + /// + /// The key of the root content. + /// The cultures to publish. + /// A value indicating options for force publishing unpublished or re-publishing unchanged content. + /// The identifier of the user performing the operation. + /// Flag indicating whether to use a background thread for the operation and immediately return to the caller. + /// Result of the publish operation. + Task> PublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey, bool useBackgroundThread) +#pragma warning disable CS0618 // Type or member is obsolete + => PublishBranchAsync(key, cultures, publishBranchFilter, userKey); +#pragma warning restore CS0618 // Type or member is obsolete + + /// + /// Gets the status of a background task that is publishing a content branch. + /// + /// The task Id. + /// True if the requested publish branch tag is still in process. + Task IsPublishingBranchAsync(Guid taskId) => Task.FromResult(false); + + /// + /// Retrieves the result of a background task that has published a content branch. + /// + /// The task Id. + /// Result of the publish operation. + Task> GetPublishBranchResultAsync(Guid taskId) => Task.FromResult(Attempt.FailWithStatus(ContentPublishingOperationStatus.TaskResultNotFound, new ContentPublishingBranchResult())); + /// /// Unpublishes multiple cultures of a single content item. /// diff --git a/src/Umbraco.Core/Services/IDataTypeService.cs b/src/Umbraco.Core/Services/IDataTypeService.cs index 592c6c49cc..9086e0765f 100644 --- a/src/Umbraco.Core/Services/IDataTypeService.cs +++ b/src/Umbraco.Core/Services/IDataTypeService.cs @@ -9,6 +9,7 @@ namespace Umbraco.Cms.Core.Services; /// public interface IDataTypeService : IService { + [Obsolete("Please use GetPagedRelationsAsync. Scheduled for removal in Umbraco 17.")] IReadOnlyDictionary> GetListViewReferences(int id) => throw new NotImplementedException(); /// @@ -16,8 +17,25 @@ public interface IDataTypeService : IService /// /// The guid Id of the /// + [Obsolete("Please use GetPagedRelationsAsync. Scheduled for removal in Umbraco 17.")] Task>, DataTypeOperationStatus>> GetReferencesAsync(Guid id); + /// + /// Gets a paged result of items which are in relation with the current data type. + /// + /// The identifier of the data type to retrieve relations for. + /// The amount of items to skip + /// The amount of items to take. + /// A paged result of objects. + /// + /// Note that the model and method signature here aligns with with how we handle retrieval of concrete Umbraco + /// relations based on documents, media and members in . + /// The intention is that we align data type relations with these so they can be handled polymorphically at the management API + /// and backoffice UI level. + /// + Task> GetPagedRelationsAsync(Guid key, int skip, int take) + => Task.FromResult(new PagedModel()); + [Obsolete("Please use IDataTypeContainerService for all data type container operations. Will be removed in V15.")] Attempt?> CreateContainer(int parentId, Guid key, string name, int userId = Constants.Security.SuperUserId); diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index 08ff2feb8c..964ec9f502 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -382,4 +382,12 @@ public interface IEntityService /// The identifier. /// When a new content or a media is saved with the key, it will have the reserved identifier. int ReserveId(Guid key); + + /// + /// Gets the GUID keys for an entity's path (provided as a comma separated list of integer Ids). + /// + /// The entity. + /// A value indicating whether to omit the entity's own key from the result. + /// The path with each ID converted to a GUID. + Guid[] GetPathKeys(ITreeEntity entity, bool omitSelf = false) => []; } diff --git a/src/Umbraco.Core/Services/IRelationService.cs b/src/Umbraco.Core/Services/IRelationService.cs index 676e7ea17c..e556b2c3e4 100644 --- a/src/Umbraco.Core/Services/IRelationService.cs +++ b/src/Umbraco.Core/Services/IRelationService.cs @@ -297,8 +297,20 @@ public interface IRelationService : IService /// /// Id of an object to check relations for /// Returns True if any relations exists with the given Id, otherwise False + [Obsolete("Please use the overload taking a RelationDirectionFilter parameter. Scheduled for removal in Umbraco 17.")] bool IsRelated(int id); + /// + /// Checks whether any relations exists for the passed in Id and direction. + /// + /// Id of an object to check relations for + /// Indicates whether to check for relations as parent, child or in either direction. + /// Returns True if any relations exists with the given Id, otherwise False + bool IsRelated(int id, RelationDirectionFilter directionFilter) +#pragma warning disable CS0618 // Type or member is obsolete + => IsRelated(id); +#pragma warning restore CS0618 // Type or member is obsolete + /// /// Checks whether two items are related /// diff --git a/src/Umbraco.Core/Services/ITrackedReferencesService.cs b/src/Umbraco.Core/Services/ITrackedReferencesService.cs index 857eb21af1..ce09fb4b3d 100644 --- a/src/Umbraco.Core/Services/ITrackedReferencesService.cs +++ b/src/Umbraco.Core/Services/ITrackedReferencesService.cs @@ -18,6 +18,20 @@ public interface ITrackedReferencesService /// A paged result of objects. Task> GetPagedRelationsForItemAsync(Guid key, long skip, long take, bool filterMustBeIsDependency); + /// + /// Gets a paged result of items which are in relation with an item in the recycle bin. + /// + /// The Umbraco object type that has recycle bin support (currently Document or Media). + /// The amount of items to skip + /// The amount of items to take. + /// + /// A boolean indicating whether to filter only the RelationTypes which are + /// 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, [])); + /// /// Gets a paged result of the descending items that have any references, given a parent id. /// diff --git a/src/Umbraco.Core/Services/LocalizedTextService.cs b/src/Umbraco.Core/Services/LocalizedTextService.cs index df4f7b3940..343224a6e9 100644 --- a/src/Umbraco.Core/Services/LocalizedTextService.cs +++ b/src/Umbraco.Core/Services/LocalizedTextService.cs @@ -361,10 +361,7 @@ public class LocalizedTextService : ILocalizedTextService result.TryAdd(dictionaryKey, key.Value); } - if (!overallResult.ContainsKey(areaAlias)) - { - overallResult.Add(areaAlias, result); - } + overallResult.TryAdd(areaAlias, result); } // Merge English Dictionary diff --git a/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs b/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs index 531b3952a7..d7562a76b9 100644 --- a/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs +++ b/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs @@ -293,12 +293,12 @@ internal abstract class ContentNavigationServiceBaseThe read lock value, should be -333 or -334 for content and media trees. /// The key of the object type to rebuild. /// Indicates whether the items are in the recycle bin. - protected async Task HandleRebuildAsync(int readLock, Guid objectTypeKey, bool trashed) + protected Task HandleRebuildAsync(int readLock, Guid objectTypeKey, bool trashed) { // This is only relevant for items in the content and media trees if (readLock != Constants.Locks.ContentTree && readLock != Constants.Locks.MediaTree) { - return; + return Task.CompletedTask; } using ICoreScope scope = _coreScopeProvider.CreateCoreScope(autoComplete: true); @@ -307,14 +307,18 @@ internal abstract class ContentNavigationServiceBase navigationModels = _navigationRepository.GetTrashedContentNodesByObjectType(objectTypeKey); BuildNavigationDictionary(_recycleBinNavigationStructure, _recycleBinRoots, navigationModels); } else { + _roots.Clear(); IEnumerable navigationModels = _navigationRepository.GetContentNodesByObjectType(objectTypeKey); BuildNavigationDictionary(_navigationStructure, _roots, navigationModels); } + + return Task.CompletedTask; } private bool TryGetParentKeyFromStructure(ConcurrentDictionary structure, Guid childKey, out Guid? parentKey) diff --git a/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs index 25c9a26389..329aa0d224 100644 --- a/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Core.Services.OperationStatus; +namespace Umbraco.Cms.Core.Services.OperationStatus; public enum ContentPublishingOperationStatus { @@ -27,5 +27,6 @@ public enum ContentPublishingOperationStatus Failed, // unspecified failure (can happen on unpublish at the time of writing) Unknown, CannotUnpublishWhenReferenced, - + Accepted, + TaskResultNotFound, } diff --git a/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs b/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs index caa5b9e054..3f6d815251 100644 --- a/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs @@ -6,5 +6,6 @@ public enum TemporaryFileOperationStatus FileExtensionNotAllowed = 1, KeyAlreadyUsed = 2, NotFound = 3, - UploadBlocked + UploadBlocked = 4, + InvalidFileName = 5, } diff --git a/src/Umbraco.Core/Services/PublicAccessService.cs b/src/Umbraco.Core/Services/PublicAccessService.cs index 61a6b186f0..d5ad2069c3 100644 --- a/src/Umbraco.Core/Services/PublicAccessService.cs +++ b/src/Umbraco.Core/Services/PublicAccessService.cs @@ -12,7 +12,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services; -internal class PublicAccessService : RepositoryService, IPublicAccessService +internal sealed class PublicAccessService : RepositoryService, IPublicAccessService { private readonly IPublicAccessRepository _publicAccessRepository; private readonly IEntityService _entityService; diff --git a/src/Umbraco.Core/Services/RelationService.cs b/src/Umbraco.Core/Services/RelationService.cs index 9166b1b548..7dfcb4ae2f 100644 --- a/src/Umbraco.Core/Services/RelationService.cs +++ b/src/Umbraco.Core/Services/RelationService.cs @@ -40,28 +40,22 @@ public class RelationService : RepositoryService, IRelationService /// public IRelation? GetById(int id) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationRepository.Get(id); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationRepository.Get(id); } /// public IRelationType? GetRelationTypeById(int id) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationTypeRepository.Get(id); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationTypeRepository.Get(id); } /// public IRelationType? GetRelationTypeById(Guid id) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationTypeRepository.Get(id); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationTypeRepository.Get(id); } /// @@ -70,10 +64,8 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetAllRelations(params int[] ids) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationRepository.GetMany(ids); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationRepository.GetMany(ids); } /// @@ -83,20 +75,16 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetAllRelationsByRelationType(int relationTypeId) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.RelationTypeId == relationTypeId); - return _relationRepository.Get(query); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.RelationTypeId == relationTypeId); + return _relationRepository.Get(query); } /// public IEnumerable GetAllRelationTypes(params int[] ids) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationTypeRepository.GetMany(ids); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationTypeRepository.GetMany(ids); } /// @@ -121,10 +109,8 @@ public class RelationService : RepositoryService, IRelationService .Take(take))); } - public int CountRelationTypes() - { - return _relationTypeRepository.Count(null); - } + /// + public int CountRelationTypes() => _relationTypeRepository.Count(null); /// public IEnumerable GetByParentId(int id) => GetByParentId(id, null); @@ -132,24 +118,22 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetByParentId(int id, string? relationTypeAlias) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + if (relationTypeAlias.IsNullOrWhiteSpace()) { - if (relationTypeAlias.IsNullOrWhiteSpace()) - { - IQuery qry1 = Query().Where(x => x.ParentId == id); - return _relationRepository.Get(qry1); - } - - IRelationType? relationType = GetRelationType(relationTypeAlias!); - if (relationType == null) - { - return Enumerable.Empty(); - } - - IQuery qry2 = - Query().Where(x => x.ParentId == id && x.RelationTypeId == relationType.Id); - return _relationRepository.Get(qry2); + IQuery qry1 = Query().Where(x => x.ParentId == id); + return _relationRepository.Get(qry1); } + + IRelationType? relationType = GetRelationType(relationTypeAlias!); + if (relationType == null) + { + return Enumerable.Empty(); + } + + IQuery qry2 = + Query().Where(x => x.ParentId == id && x.RelationTypeId == relationType.Id); + return _relationRepository.Get(qry2); } /// @@ -165,24 +149,22 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetByChildId(int id, string? relationTypeAlias) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + if (relationTypeAlias.IsNullOrWhiteSpace()) { - if (relationTypeAlias.IsNullOrWhiteSpace()) - { - IQuery qry1 = Query().Where(x => x.ChildId == id); - return _relationRepository.Get(qry1); - } - - IRelationType? relationType = GetRelationType(relationTypeAlias!); - if (relationType == null) - { - return Enumerable.Empty(); - } - - IQuery qry2 = - Query().Where(x => x.ChildId == id && x.RelationTypeId == relationType.Id); - return _relationRepository.Get(qry2); + IQuery qry1 = Query().Where(x => x.ChildId == id); + return _relationRepository.Get(qry1); } + + IRelationType? relationType = GetRelationType(relationTypeAlias!); + if (relationType == null) + { + return Enumerable.Empty(); + } + + IQuery qry2 = + Query().Where(x => x.ChildId == id && x.RelationTypeId == relationType.Id); + return _relationRepository.Get(qry2); } /// @@ -195,39 +177,34 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetByParentOrChildId(int id) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.ChildId == id || x.ParentId == id); - return _relationRepository.Get(query); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.ChildId == id || x.ParentId == id); + return _relationRepository.Get(query); } + /// public IEnumerable GetByParentOrChildId(int id, string relationTypeAlias) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IRelationType? relationType = GetRelationType(relationTypeAlias); + if (relationType == null) { - IRelationType? relationType = GetRelationType(relationTypeAlias); - if (relationType == null) - { - return Enumerable.Empty(); - } - - IQuery query = Query().Where(x => - (x.ChildId == id || x.ParentId == id) && x.RelationTypeId == relationType.Id); - return _relationRepository.Get(query); + return Enumerable.Empty(); } + + IQuery query = Query().Where(x => + (x.ChildId == id || x.ParentId == id) && x.RelationTypeId == relationType.Id); + return _relationRepository.Get(query); } /// public IRelation? GetByParentAndChildId(int parentId, int childId, IRelationType relationType) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.ParentId == parentId && - x.ChildId == childId && - x.RelationTypeId == relationType.Id); - return _relationRepository.Get(query).FirstOrDefault(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.ParentId == parentId && + x.ChildId == childId && + x.RelationTypeId == relationType.Id); + return _relationRepository.Get(query).FirstOrDefault(); } /// @@ -260,47 +237,41 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetByRelationTypeId(int relationTypeId) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.RelationTypeId == relationTypeId); - return _relationRepository.Get(query); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.RelationTypeId == relationTypeId); + return _relationRepository.Get(query); } /// public IEnumerable GetPagedByRelationTypeId(int relationTypeId, long pageIndex, int pageSize, out long totalRecords, Ordering? ordering = null) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery? query = Query().Where(x => x.RelationTypeId == relationTypeId); - return _relationRepository.GetPagedRelationsByQuery(query, pageIndex, pageSize, out totalRecords, ordering); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery? query = Query().Where(x => x.RelationTypeId == relationTypeId); + return _relationRepository.GetPagedRelationsByQuery(query, pageIndex, pageSize, out totalRecords, ordering); } + /// public async Task> GetPagedByChildKeyAsync(Guid childKey, int skip, int take, string? relationTypeAlias) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return await _relationRepository.GetPagedByChildKeyAsync(childKey, skip, take, relationTypeAlias); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return await _relationRepository.GetPagedByChildKeyAsync(childKey, skip, take, relationTypeAlias); } + /// public Task, RelationOperationStatus>> GetPagedByRelationTypeKeyAsync(Guid key, int skip, int take, Ordering? ordering = null) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IRelationType? relationType = _relationTypeRepository.Get(key); + if (relationType is null) { - IRelationType? relationType = _relationTypeRepository.Get(key); - if (relationType is null) - { - return Task.FromResult(Attempt.FailWithStatus, RelationOperationStatus>(RelationOperationStatus.RelationTypeNotFound, null!)); - } - - PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); - - IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); - IEnumerable relations = _relationRepository.GetPagedRelationsByQuery(query, pageNumber, pageSize, out var totalRecords, ordering); - return Task.FromResult(Attempt.SucceedWithStatus(RelationOperationStatus.Success, new PagedModel(totalRecords, relations))); + return Task.FromResult(Attempt.FailWithStatus, RelationOperationStatus>(RelationOperationStatus.RelationTypeNotFound, null!)); } + + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); + + IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); + IEnumerable relations = _relationRepository.GetPagedRelationsByQuery(query, pageNumber, pageSize, out var totalRecords, ordering); + return Task.FromResult(Attempt.SucceedWithStatus(RelationOperationStatus.Success, new PagedModel(totalRecords, relations))); } /// @@ -371,19 +342,15 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetPagedParentEntitiesByChildId(int id, long pageIndex, int pageSize, out long totalChildren, params UmbracoObjectTypes[] entityTypes) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationRepository.GetPagedParentEntitiesByChildId(id, pageIndex, pageSize, out totalChildren, entityTypes.Select(x => x.GetGuid()).ToArray()); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationRepository.GetPagedParentEntitiesByChildId(id, pageIndex, pageSize, out totalChildren, entityTypes.Select(x => x.GetGuid()).ToArray()); } /// public IEnumerable GetPagedChildEntitiesByParentId(int id, long pageIndex, int pageSize, out long totalChildren, params UmbracoObjectTypes[] entityTypes) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationRepository.GetPagedChildEntitiesByParentId(id, pageIndex, pageSize, out totalChildren, entityTypes.Select(x => x.GetGuid()).ToArray()); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationRepository.GetPagedChildEntitiesByParentId(id, pageIndex, pageSize, out totalChildren, entityTypes.Select(x => x.GetGuid()).ToArray()); } /// @@ -417,22 +384,20 @@ public class RelationService : RepositoryService, IRelationService // TODO: We don't check if this exists first, it will throw some sort of data integrity exception if it already exists, is that ok? var relation = new Relation(parentId, childId, relationType); - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + EventMessages eventMessages = EventMessagesFactory.Get(); + var savingNotification = new RelationSavingNotification(relation, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) { - EventMessages eventMessages = EventMessagesFactory.Get(); - var savingNotification = new RelationSavingNotification(relation, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return relation; // TODO: returning sth that does not exist here?! - } - - _relationRepository.Save(relation); - scope.Notifications.Publish( - new RelationSavedNotification(relation, eventMessages).WithStateFrom(savingNotification)); scope.Complete(); - return relation; + return relation; // TODO: returning sth that does not exist here?! } + + _relationRepository.Save(relation); + scope.Notifications.Publish( + new RelationSavedNotification(relation, eventMessages).WithStateFrom(savingNotification)); + scope.Complete(); + return relation; } /// @@ -468,31 +433,37 @@ public class RelationService : RepositoryService, IRelationService /// public bool HasRelations(IRelationType relationType) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); - return _relationRepository.Get(query).Any(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); + return _relationRepository.Get(query).Any(); } /// - public bool IsRelated(int id) + public bool IsRelated(int id) => IsRelated(id, RelationDirectionFilter.Any); + + /// + public bool IsRelated(int id, RelationDirectionFilter directionFilter) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query(); + + query = directionFilter switch { - IQuery query = Query().Where(x => x.ParentId == id || x.ChildId == id); - return _relationRepository.Get(query).Any(); - } + RelationDirectionFilter.Parent => query.Where(x => x.ParentId == id), + RelationDirectionFilter.Child => query.Where(x => x.ChildId == id), + RelationDirectionFilter.Any => query.Where(x => x.ParentId == id || x.ChildId == id), + _ => throw new ArgumentOutOfRangeException(nameof(directionFilter)), + }; + + return _relationRepository.Get(query).Any(); } /// public bool AreRelated(int parentId, int childId) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.ParentId == parentId && x.ChildId == childId); - return _relationRepository.Get(query).Any(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.ParentId == parentId && x.ChildId == childId); + return _relationRepository.Get(query).Any(); } /// @@ -517,65 +488,61 @@ public class RelationService : RepositoryService, IRelationService /// public void Save(IRelation relation) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + EventMessages eventMessages = EventMessagesFactory.Get(); + var savingNotification = new RelationSavingNotification(relation, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) { - EventMessages eventMessages = EventMessagesFactory.Get(); - var savingNotification = new RelationSavingNotification(relation, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return; - } - - _relationRepository.Save(relation); scope.Complete(); - scope.Notifications.Publish( - new RelationSavedNotification(relation, eventMessages).WithStateFrom(savingNotification)); + return; } + + _relationRepository.Save(relation); + scope.Complete(); + scope.Notifications.Publish( + new RelationSavedNotification(relation, eventMessages).WithStateFrom(savingNotification)); } + /// public void Save(IEnumerable relations) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IRelation[] relationsA = relations.ToArray(); + + EventMessages messages = EventMessagesFactory.Get(); + var savingNotification = new RelationSavingNotification(relationsA, messages); + if (scope.Notifications.PublishCancelable(savingNotification)) { - IRelation[] relationsA = relations.ToArray(); - - EventMessages messages = EventMessagesFactory.Get(); - var savingNotification = new RelationSavingNotification(relationsA, messages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return; - } - - _relationRepository.Save(relationsA); scope.Complete(); - scope.Notifications.Publish( - new RelationSavedNotification(relationsA, messages).WithStateFrom(savingNotification)); + return; } + + _relationRepository.Save(relationsA); + scope.Complete(); + scope.Notifications.Publish( + new RelationSavedNotification(relationsA, messages).WithStateFrom(savingNotification)); } /// public void Save(IRelationType relationType) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + EventMessages eventMessages = EventMessagesFactory.Get(); + var savingNotification = new RelationTypeSavingNotification(relationType, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) { - EventMessages eventMessages = EventMessagesFactory.Get(); - var savingNotification = new RelationTypeSavingNotification(relationType, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return; - } - - _relationTypeRepository.Save(relationType); - Audit(AuditType.Save, Constants.Security.SuperUserId, relationType.Id, $"Saved relation type: {relationType.Name}"); scope.Complete(); - scope.Notifications.Publish( - new RelationTypeSavedNotification(relationType, eventMessages).WithStateFrom(savingNotification)); + return; } + + _relationTypeRepository.Save(relationType); + Audit(AuditType.Save, Constants.Security.SuperUserId, relationType.Id, $"Saved relation type: {relationType.Name}"); + scope.Complete(); + scope.Notifications.Publish( + new RelationTypeSavedNotification(relationType, eventMessages).WithStateFrom(savingNotification)); } + /// public async Task> CreateAsync(IRelationType relationType, Guid userKey) { if (relationType.Id != 0) @@ -591,6 +558,7 @@ public class RelationService : RepositoryService, IRelationService userKey); } + /// public async Task> UpdateAsync(IRelationType relationType, Guid userKey) => await SaveAsync( relationType, @@ -646,105 +614,97 @@ public class RelationService : RepositoryService, IRelationService /// public void Delete(IRelation relation) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + EventMessages eventMessages = EventMessagesFactory.Get(); + var deletingNotification = new RelationDeletingNotification(relation, eventMessages); + if (scope.Notifications.PublishCancelable(deletingNotification)) { - EventMessages eventMessages = EventMessagesFactory.Get(); - var deletingNotification = new RelationDeletingNotification(relation, eventMessages); - if (scope.Notifications.PublishCancelable(deletingNotification)) - { - scope.Complete(); - return; - } - - _relationRepository.Delete(relation); scope.Complete(); - scope.Notifications.Publish( - new RelationDeletedNotification(relation, eventMessages).WithStateFrom(deletingNotification)); + return; } + + _relationRepository.Delete(relation); + scope.Complete(); + scope.Notifications.Publish( + new RelationDeletedNotification(relation, eventMessages).WithStateFrom(deletingNotification)); } /// public void Delete(IRelationType relationType) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + EventMessages eventMessages = EventMessagesFactory.Get(); + var deletingNotification = new RelationTypeDeletingNotification(relationType, eventMessages); + if (scope.Notifications.PublishCancelable(deletingNotification)) { - EventMessages eventMessages = EventMessagesFactory.Get(); - var deletingNotification = new RelationTypeDeletingNotification(relationType, eventMessages); - if (scope.Notifications.PublishCancelable(deletingNotification)) - { - scope.Complete(); - return; - } - - _relationTypeRepository.Delete(relationType); scope.Complete(); - scope.Notifications.Publish( - new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification)); + return; } + + _relationTypeRepository.Delete(relationType); + scope.Complete(); + scope.Notifications.Publish( + new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification)); } + /// public async Task> DeleteAsync(Guid key, Guid userKey) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IRelationType? relationType = _relationTypeRepository.Get(key); + if (relationType is null) { - IRelationType? relationType = _relationTypeRepository.Get(key); - if (relationType is null) - { - return Attempt.FailWithStatus(RelationTypeOperationStatus.NotFound, null); - } - - EventMessages eventMessages = EventMessagesFactory.Get(); - var deletingNotification = new RelationTypeDeletingNotification(relationType, eventMessages); - if (scope.Notifications.PublishCancelable(deletingNotification)) - { - scope.Complete(); - return Attempt.FailWithStatus(RelationTypeOperationStatus.CancelledByNotification, null); - } - - _relationTypeRepository.Delete(relationType); - var currentUser = await _userIdKeyResolver.GetAsync(userKey); - Audit(AuditType.Delete, currentUser, relationType.Id, "Deleted relation type"); - scope.Notifications.Publish(new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification)); - scope.Complete(); - return Attempt.SucceedWithStatus(RelationTypeOperationStatus.Success, relationType); + return Attempt.FailWithStatus(RelationTypeOperationStatus.NotFound, null); } + + EventMessages eventMessages = EventMessagesFactory.Get(); + var deletingNotification = new RelationTypeDeletingNotification(relationType, eventMessages); + if (scope.Notifications.PublishCancelable(deletingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(RelationTypeOperationStatus.CancelledByNotification, null); + } + + _relationTypeRepository.Delete(relationType); + var currentUser = await _userIdKeyResolver.GetAsync(userKey); + Audit(AuditType.Delete, currentUser, relationType.Id, "Deleted relation type"); + scope.Notifications.Publish(new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification)); + scope.Complete(); + return await Task.FromResult(Attempt.SucceedWithStatus(RelationTypeOperationStatus.Success, relationType)); } /// public void DeleteRelationsOfType(IRelationType relationType) { var relations = new List(); - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); + var allRelations = _relationRepository.Get(query).ToList(); + relations.AddRange(allRelations); + + // TODO: N+1, we should be able to do this in a single call + foreach (IRelation relation in relations) { - IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); - var allRelations = _relationRepository.Get(query).ToList(); - relations.AddRange(allRelations); - - // TODO: N+1, we should be able to do this in a single call - foreach (IRelation relation in relations) - { - _relationRepository.Delete(relation); - } - - scope.Complete(); - - scope.Notifications.Publish(new RelationDeletedNotification(relations, EventMessagesFactory.Get())); + _relationRepository.Delete(relation); } + + scope.Complete(); + + scope.Notifications.Publish(new RelationDeletedNotification(relations, EventMessagesFactory.Get())); } + /// public bool AreRelated(int parentId, int childId, IRelationType relationType) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => - x.ParentId == parentId && x.ChildId == childId && x.RelationTypeId == relationType.Id); - return _relationRepository.Get(query).Any(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => + x.ParentId == parentId && x.ChildId == childId && x.RelationTypeId == relationType.Id); + return _relationRepository.Get(query).Any(); } + /// public IEnumerable GetAllowedObjectTypes() => - new[] - { + [ UmbracoObjectTypes.Document, UmbracoObjectTypes.Media, UmbracoObjectTypes.Member, @@ -755,17 +715,15 @@ public class RelationService : RepositoryService, IRelationService UmbracoObjectTypes.MemberGroup, UmbracoObjectTypes.ROOT, UmbracoObjectTypes.RecycleBin, - }; + ]; #region Private Methods private IRelationType? GetRelationType(string relationTypeAlias) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.Alias == relationTypeAlias); - return _relationTypeRepository.Get(query).FirstOrDefault(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.Alias == relationTypeAlias); + return _relationTypeRepository.Get(query).FirstOrDefault(); } private IEnumerable GetRelationsByListOfTypeIds(IEnumerable relationTypeIds) diff --git a/src/Umbraco.Core/Services/TemporaryFileService.cs b/src/Umbraco.Core/Services/TemporaryFileService.cs index 6dc964d23d..12a78b0739 100644 --- a/src/Umbraco.Core/Services/TemporaryFileService.cs +++ b/src/Umbraco.Core/Services/TemporaryFileService.cs @@ -45,7 +45,6 @@ internal sealed class TemporaryFileService : ITemporaryFileService return Attempt.FailWithStatus(TemporaryFileOperationStatus.KeyAlreadyUsed, null); } - await using Stream dataStream = createModel.OpenReadStream(); dataStream.Seek(0, SeekOrigin.Begin); if (_fileStreamSecurityValidator.IsConsideredSafe(dataStream) is false) @@ -53,13 +52,12 @@ internal sealed class TemporaryFileService : ITemporaryFileService return Attempt.FailWithStatus(TemporaryFileOperationStatus.UploadBlocked, null); } - temporaryFileModel = new TemporaryFileModel { Key = createModel.Key, FileName = createModel.FileName, OpenReadStream = createModel.OpenReadStream, - AvailableUntil = DateTime.Now.Add(_runtimeSettings.TemporaryFileLifeTime) + AvailableUntil = DateTime.Now.Add(_runtimeSettings.TemporaryFileLifeTime), }; await _temporaryFileRepository.SaveAsync(temporaryFileModel); @@ -68,17 +66,29 @@ internal sealed class TemporaryFileService : ITemporaryFileService } private TemporaryFileOperationStatus Validate(TemporaryFileModelBase temporaryFileModel) - => IsAllowedFileExtension(temporaryFileModel) == false - ? TemporaryFileOperationStatus.FileExtensionNotAllowed - : TemporaryFileOperationStatus.Success; - - private bool IsAllowedFileExtension(TemporaryFileModelBase temporaryFileModel) { - var extension = Path.GetExtension(temporaryFileModel.FileName)[1..]; + if (IsAllowedFileExtension(temporaryFileModel.FileName) == false) + { + return TemporaryFileOperationStatus.FileExtensionNotAllowed; + } + if (IsValidFileName(temporaryFileModel.FileName) == false) + { + return TemporaryFileOperationStatus.InvalidFileName; + } + + return TemporaryFileOperationStatus.Success; + } + + private bool IsAllowedFileExtension(string fileName) + { + var extension = Path.GetExtension(fileName)[1..]; return _contentSettings.IsFileAllowedForUpload(extension); } + private static bool IsValidFileName(string fileName) => + !string.IsNullOrEmpty(fileName) && fileName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; + public async Task> DeleteAsync(Guid key) { TemporaryFileModel? model = await _temporaryFileRepository.GetAsync(key); diff --git a/src/Umbraco.Core/Services/TrackedReferencesService.cs b/src/Umbraco.Core/Services/TrackedReferencesService.cs index b193ee07be..d1ffe9ea9d 100644 --- a/src/Umbraco.Core/Services/TrackedReferencesService.cs +++ b/src/Umbraco.Core/Services/TrackedReferencesService.cs @@ -30,6 +30,21 @@ public class TrackedReferencesService : ITrackedReferencesService return Task.FromResult(pagedModel); } + public async Task> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) + { + Guid objectTypeKey = objectType switch + { + UmbracoObjectTypes.Document => Constants.ObjectTypes.Document, + UmbracoObjectTypes.Media => Constants.ObjectTypes.Media, + _ => throw new ArgumentOutOfRangeException(nameof(objectType), "Only documents and media have recycle bin support."), + }; + + 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); + } + public Task> GetPagedDescendantsInReferencesAsync(Guid parentKey, long skip, long take, bool filterMustBeIsDependency) { using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index e928ec16a0..1d120c6954 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -2400,7 +2400,7 @@ internal partial class UserService : RepositoryService, IUserService { if (pathIds.Length == 0) { - return new EntityPermissionCollection(Enumerable.Empty()); + return new EntityPermissionCollection([]); } // get permissions for all nodes in the path by group diff --git a/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs b/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs index b46c1405e3..581cd168a3 100644 --- a/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs +++ b/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs @@ -306,7 +306,7 @@ namespace Umbraco.Cms.Core.Strings return text; } - private string RemoveSurrogatePairs(string text) + private static string RemoveSurrogatePairs(string text) { var input = text.AsSpan(); Span output = input.Length <= 1024 ? stackalloc char[input.Length] : new char[text.Length]; @@ -622,7 +622,8 @@ namespace Umbraco.Cms.Core.Strings } var input = text.ToCharArray(); - var output = new char[input.Length * 2]; + int outputLength = input.Length * 2; + Span output = outputLength <= 1024 ? stackalloc char[outputLength] : new char[outputLength]; var opos = 0; var a = input.Length > 0 ? input[0] : char.MinValue; var upos = char.IsUpper(a) ? 1 : 0; @@ -666,7 +667,7 @@ namespace Umbraco.Cms.Core.Strings output[opos++] = a; } - return new string(output, 0, opos); + return new string(output[..opos]); } #endregion diff --git a/src/Umbraco.Core/Strings/Utf8ToAsciiConverter.cs b/src/Umbraco.Core/Strings/Utf8ToAsciiConverter.cs index ed16192bd1..f997328aff 100644 --- a/src/Umbraco.Core/Strings/Utf8ToAsciiConverter.cs +++ b/src/Umbraco.Core/Strings/Utf8ToAsciiConverter.cs @@ -52,11 +52,10 @@ public static class Utf8ToAsciiConverter // this is faster although it uses more memory // but... we should be filtering short strings only... - var output = new char[input.Length * 3]; // *3 because of things such as OE + int outputLength = input.Length * 3; // *3 because of things such as OE + Span output = outputLength <= 1024 ? stackalloc char[outputLength] : new char[outputLength]; var len = ToAscii(input, output, fail); - var array = new char[len]; - Array.Copy(output, array, len); - return array; + return output[..len].ToArray(); // var temp = new StringBuilder(input.Length + 16); // default is 16, start with at least input length + little extra // ToAscii(input, temp); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 7fe790e591..13b2679b2c 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -14,6 +14,7 @@ using Umbraco.Cms.Core.DistributedLocking; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Handlers; using Umbraco.Cms.Core.HealthChecks.NotificationMethods; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Install; using Umbraco.Cms.Core.Logging.Serilog.Enrichers; @@ -43,7 +44,6 @@ using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Infrastructure.DistributedLocking; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Infrastructure.HealthChecks; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.Install; using Umbraco.Cms.Infrastructure.Mail; using Umbraco.Cms.Infrastructure.Mail.Interfaces; @@ -224,8 +224,13 @@ public static partial class UmbracoBuilderExtensions builder.AddInstaller(); - // Services required to run background jobs (with out the handler) - builder.Services.AddSingleton(); + // Services required to run background jobs + // We can simplify this registration once the obsolete IBackgroundTaskQueue is removed. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(s => s.GetRequiredService()); +#pragma warning disable CS0618 // Type or member is obsolete + builder.Services.AddSingleton(s => s.GetRequiredService()); +#pragma warning restore CS0618 // Type or member is obsolete builder.Services.AddTransient(); diff --git a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentChanges.cs b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentChanges.cs index 66859edd7d..0b4310c4b4 100644 --- a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentChanges.cs +++ b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentChanges.cs @@ -1,8 +1,8 @@ -using Examine; +using Examine; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Changes; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Examine.Deferred; diff --git a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs index ecc34de76d..f1f0e25453 100644 --- a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs +++ b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs @@ -1,10 +1,10 @@ using Examine; using Examine.Search; using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Changes; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Examine.Deferred; diff --git a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs index 01449f37a6..6a9733fde8 100644 --- a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs +++ b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs @@ -1,9 +1,9 @@ -using Examine; +using Examine; using Examine.Search; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Examine.Deferred; diff --git a/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs b/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs index 9e3fd90f7d..fa3c134e4c 100644 --- a/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs +++ b/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs @@ -4,11 +4,11 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Changes; using Umbraco.Cms.Infrastructure.Examine.Deferred; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.Search; namespace Umbraco.Cms.Infrastructure.Examine; @@ -30,7 +30,7 @@ internal sealed class DeliveryApiIndexingHandler : IDeliveryApiIndexingHandler private readonly IDeliveryApiContentIndexHelper _deliveryApiContentIndexHelper; private readonly IBackgroundTaskQueue _backgroundTaskQueue; private readonly IDeliveryApiCompositeIdHandler _deliveryApiCompositeIdHandler; - + public DeliveryApiIndexingHandler( ExamineIndexingMainDomHandler mainDomHandler, diff --git a/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs b/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs index 4efdf9ff12..2ca1bc8f79 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs @@ -2,10 +2,10 @@ using System.Globalization; using Examine; using Examine.Search; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.Search; using Umbraco.Extensions; diff --git a/src/Umbraco.Infrastructure/HostedServices/BackgroundTaskQueue.cs b/src/Umbraco.Infrastructure/HostedServices/BackgroundTaskQueue.cs index 522fae5c4d..55b28f984e 100644 --- a/src/Umbraco.Infrastructure/HostedServices/BackgroundTaskQueue.cs +++ b/src/Umbraco.Infrastructure/HostedServices/BackgroundTaskQueue.cs @@ -8,7 +8,9 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// /// Borrowed from https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-5.0 /// +#pragma warning disable CS0618 // Type or member is obsolete public class BackgroundTaskQueue : IBackgroundTaskQueue +#pragma warning restore CS0618 // Type or member is obsolete { private readonly SemaphoreSlim _signal = new(0); diff --git a/src/Umbraco.Infrastructure/HostedServices/IBackgroundTaskQueue.cs b/src/Umbraco.Infrastructure/HostedServices/IBackgroundTaskQueue.cs index aa89d59d77..983af4be9a 100644 --- a/src/Umbraco.Infrastructure/HostedServices/IBackgroundTaskQueue.cs +++ b/src/Umbraco.Infrastructure/HostedServices/IBackgroundTaskQueue.cs @@ -6,15 +6,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// /// Borrowed from https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-5.0 /// -public interface IBackgroundTaskQueue +[Obsolete("This has been relocated into Umbraco.Cms.Core. This definition in Umbraco.Cms.Infrastructure is scheduled for removal in Umbraco 17.")] +public interface IBackgroundTaskQueue : Core.HostedServices.IBackgroundTaskQueue { - /// - /// Enqueue a work item to be executed on in the background. - /// - void QueueBackgroundWorkItem(Func workItem); - - /// - /// Dequeue the first item on the queue. - /// - Task?> DequeueAsync(CancellationToken cancellationToken); } diff --git a/src/Umbraco.Infrastructure/Mapping/RelationModelMapDefinition.cs b/src/Umbraco.Infrastructure/Mapping/RelationModelMapDefinition.cs index 8ace25c07f..1f0c986e0d 100644 --- a/src/Umbraco.Infrastructure/Mapping/RelationModelMapDefinition.cs +++ b/src/Umbraco.Infrastructure/Mapping/RelationModelMapDefinition.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; @@ -19,6 +19,7 @@ public class RelationModelMapDefinition : IMapDefinition target.RelationTypeName = source.RelationTypeName; target.RelationTypeIsBidirectional = source.RelationTypeIsBidirectional; target.RelationTypeIsDependency = source.RelationTypeIsDependency; + target.ContentTypeKey = source.ChildContentTypeKey; target.ContentTypeAlias = source.ChildContentTypeAlias; target.ContentTypeIcon = source.ChildContentTypeIcon; target.ContentTypeName = source.ChildContentTypeName; diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index fdaadb3027..8ff1fe381b 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -452,10 +452,12 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended return AddGroupBy(isContent, isMedia, isMember, sql, true); } + protected Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, bool isCount = false) + => GetBase(isContent, isMedia, isMember, filter, [], isCount); + // gets the base SELECT + FROM [+ filter] sql // always from the 'current' content version - protected Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, - bool isCount = false) + protected Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, Guid[] objectTypes, bool isCount = false) { Sql sql = Sql(); @@ -469,8 +471,19 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended .Select(x => x.NodeId, x => x.Trashed, x => x.ParentId, x => x.UserId, x => x.Level, x => x.Path) .AndSelect(x => x.SortOrder, x => x.UniqueId, x => x.Text, x => x.NodeObjectType, - x => x.CreateDate) - .Append(", COUNT(child.id) AS children"); + x => x.CreateDate); + + if (objectTypes.Length == 0) + { + sql.Append(", COUNT(child.id) AS children"); + } + else + { + // The following is safe from SQL injection as we are dealing with GUIDs, not strings. + // Upper-case is necessary for SQLite, and also works for SQL Server. + var objectTypesForInClause = string.Join("','", objectTypes.Select(x => x.ToString().ToUpperInvariant())); + sql.Append($", SUM(CASE WHEN child.nodeObjectType IN ('{objectTypesForInClause}') THEN 1 ELSE 0 END) AS children"); + } if (isContent || isMedia || isMember) { @@ -545,7 +558,7 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, Action>? filter, Guid[] objectTypes) { - Sql sql = GetBase(isContent, isMedia, isMember, filter, isCount); + Sql sql = GetBase(isContent, isMedia, isMember, filter, objectTypes, isCount); if (objectTypes.Length > 0) { sql.WhereIn(x => x.NodeObjectType, objectTypes); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberTypeRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberTypeRepository.cs index d4790a387a..61127b766b 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberTypeRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberTypeRepository.cs @@ -228,19 +228,11 @@ internal class MemberTypeRepository : ContentTypeRepositoryBase, IM ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper); foreach (IPropertyType propertyType in memberType.PropertyTypes) { - if (builtinProperties.ContainsKey(propertyType.Alias)) + // this reset's its current data type reference which will be re-assigned based on the property editor assigned on the next line + if (builtinProperties.TryGetValue(propertyType.Alias, out PropertyType? propDefinition)) { - // this reset's its current data type reference which will be re-assigned based on the property editor assigned on the next line - if (builtinProperties.TryGetValue(propertyType.Alias, out PropertyType? propDefinition)) - { - propertyType.DataTypeId = propDefinition.DataTypeId; - propertyType.DataTypeKey = propDefinition.DataTypeKey; - } - else - { - propertyType.DataTypeId = 0; - propertyType.DataTypeKey = default; - } + propertyType.DataTypeId = propDefinition.DataTypeId; + propertyType.DataTypeKey = propDefinition.DataTypeKey; } } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs index 126a62674a..366c5c6a26 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs @@ -1,6 +1,4 @@ using NPoco; -using Umbraco.Cms.Core.DeliveryApi; -using Umbraco.Cms.Core.Media.EmbedProviders; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Dtos; @@ -67,7 +65,7 @@ public class PublishStatusRepository: IPublishStatusRepository List? databaseRecords = await Database.FetchAsync(sql); IDictionary> result = Map(databaseRecords); - return result.ContainsKey(documentKey) ? result[documentKey] : new HashSet(); + return result.TryGetValue(documentKey, out ISet? value) ? value : new HashSet(); } public async Task>> GetDescendantsOrSelfPublishStatusAsync(Guid rootDocumentKey, CancellationToken cancellationToken) @@ -98,7 +96,7 @@ public class PublishStatusRepository: IPublishStatusRepository x=> (ISet) x.Where(x=> IsPublished(x)).Select(y=>y.IsoCode).ToHashSet()); } - private bool IsPublished(PublishStatusDto publishStatusDto) + private static bool IsPublished(PublishStatusDto publishStatusDto) { switch ((ContentVariation)publishStatusDto.ContentTypeVariation) { @@ -112,7 +110,7 @@ public class PublishStatusRepository: IPublishStatusRepository } } - private class PublishStatusDto + private sealed class PublishStatusDto { public const string DocumentVariantPublishStatusColumnName = "variantPublished"; @@ -133,5 +131,4 @@ public class PublishStatusRepository: IPublishStatusRepository [Column(DocumentVariantPublishStatusColumnName)] public bool DocumentVariantPublishStatus { get; set; } } - } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs index a38bf4547f..5c403445e0 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs @@ -34,10 +34,10 @@ internal class RelationRepository : EntityRepositoryBase, IRelat } public IEnumerable GetPagedParentEntitiesByChildId(int childId, long pageIndex, int pageSize, out long totalRecords, params Guid[] entityTypes) - => GetPagedParentEntitiesByChildId(childId, pageIndex, pageSize, out totalRecords, new int[0], entityTypes); + => GetPagedParentEntitiesByChildId(childId, pageIndex, pageSize, out totalRecords, [], entityTypes); public IEnumerable GetPagedChildEntitiesByParentId(int parentId, long pageIndex, int pageSize, out long totalRecords, params Guid[] entityTypes) - => GetPagedChildEntitiesByParentId(parentId, pageIndex, pageSize, out totalRecords, new int[0], entityTypes); + => GetPagedChildEntitiesByParentId(parentId, pageIndex, pageSize, out totalRecords, [], entityTypes); public Task> GetPagedByChildKeyAsync(Guid childKey, int skip, int take, string? relationTypeAlias) { @@ -475,6 +475,9 @@ internal class RelationItemDto [Column(Name = "nodeObjectType")] public Guid ChildNodeObjectType { get; set; } + [Column(Name = "contentTypeKey")] + public Guid ChildContentTypeKey { get; set; } + [Column(Name = "contentTypeIcon")] public string? ChildContentTypeIcon { get; set; } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationTypeRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationTypeRepository.cs index af7458bab0..8c84f2ae65 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationTypeRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationTypeRepository.cs @@ -17,7 +17,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; /// /// Represents a repository for doing CRUD operations for /// -internal class RelationTypeRepository : EntityRepositoryBase, IRelationTypeRepository +internal sealed class RelationTypeRepository : EntityRepositoryBase, IRelationTypeRepository { public RelationTypeRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger) : base(scopeAccessor, cache, logger) @@ -27,7 +27,7 @@ internal class RelationTypeRepository : EntityRepositoryBase protected override IRepositoryCachePolicy CreateCachePolicy() => new FullDataSetRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, GetEntityId, /*expires:*/ true); - private void CheckNullObjectTypeValues(IRelationType entity) + private static void CheckNullObjectTypeValues(IRelationType entity) { if (entity.ParentObjectType.HasValue && entity.ParentObjectType == Guid.Empty) { @@ -66,12 +66,12 @@ internal class RelationTypeRepository : EntityRepositoryBase public IEnumerable GetMany(params Guid[]? ids) { // should not happen due to the cache policy - if (ids?.Any() ?? false) + if (ids is { Length: not 0 }) { throw new NotImplementedException(); } - return GetMany(new int[0]); + return GetMany(Array.Empty()); } protected override IEnumerable PerformGetByQuery(IQuery query) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs index 3f97d951ef..169cc97a25 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using NPoco; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; @@ -27,8 +28,16 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement } Sql innerUnionSqlChild = _scopeAccessor.AmbientScope.Database.SqlContext.Sql().Select( - "[cn].uniqueId as [key]", "[pn].uniqueId as otherKey, [cr].childId as id", "[cr].parentId as otherId", "[rt].[alias]", "[rt].[name]", - "[rt].[isDependency]", "[rt].[dual]") + "[cn].uniqueId as [key]", + "[cn].trashed as [trashed]", + "[cn].nodeObjectType as [nodeObjectType]", + "[pn].uniqueId as otherKey," + + "[cr].childId as id", + "[cr].parentId as otherId", + "[rt].[alias]", + "[rt].[name]", + "[rt].[isDependency]", + "[rt].[dual]") .From("cr") .InnerJoin("rt") .On((cr, rt) => rt.Dual == false && rt.Id == cr.RelationType, "cr", "rt") @@ -38,8 +47,16 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement .On((cr, pn) => cr.ParentId == pn.NodeId, "cr", "pn"); Sql innerUnionSqlDualParent = _scopeAccessor.AmbientScope.Database.SqlContext.Sql().Select( - "[pn].uniqueId as [key]", "[cn].uniqueId as otherKey, [dpr].parentId as id", "[dpr].childId as otherId", "[dprt].[alias]", "[dprt].[name]", - "[dprt].[isDependency]", "[dprt].[dual]") + "[pn].uniqueId as [key]", + "[pn].trashed as [trashed]", + "[pn].nodeObjectType as [nodeObjectType]", + "[cn].uniqueId as otherKey," + + "[dpr].parentId as id", + "[dpr].childId as otherId", + "[dprt].[alias]", + "[dprt].[name]", + "[dprt].[isDependency]", + "[dprt].[dual]") .From("dpr") .InnerJoin("dprt") .On( @@ -50,8 +67,16 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement .On((dpr, pn) => dpr.ParentId == pn.NodeId, "dpr", "pn"); Sql innerUnionSql3 = _scopeAccessor.AmbientScope.Database.SqlContext.Sql().Select( - "[cn].uniqueId as [key]", "[pn].uniqueId as otherKey, [dcr].childId as id", "[dcr].parentId as otherId", "[dcrt].[alias]", "[dcrt].[name]", - "[dcrt].[isDependency]", "[dcrt].[dual]") + "[cn].uniqueId as [key]", + "[cn].trashed as [trashed]", + "[cn].nodeObjectType as [nodeObjectType]", + "[pn].uniqueId as otherKey," + + "[dcr].childId as id", + "[dcr].parentId as otherId", + "[dcrt].[alias]", + "[dcrt].[name]", + "[dcrt].[isDependency]", + "[dcrt].[dual]") .From("dcr") .InnerJoin("dcrt") .On( @@ -72,6 +97,32 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement long take, bool filterMustBeIsDependency, out long totalRecords) + => GetPagedRelations( + x => x.Key == key, + skip, + take, + filterMustBeIsDependency, + out totalRecords); + + public IEnumerable GetPagedRelationsForRecycleBin( + Guid objectTypeKey, + long skip, + long take, + bool filterMustBeIsDependency, + out long totalRecords) + => GetPagedRelations( + x => x.NodeObjectType == objectTypeKey && x.Trashed == true, + skip, + take, + filterMustBeIsDependency, + out totalRecords); + + private IEnumerable GetPagedRelations( + Expression> itemsFilter, + long skip, + long take, + bool filterMustBeIsDependency, + out long totalRecords) { Sql innerUnionSql = GetInnerUnionSql(); Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql().SelectDistinct( @@ -80,6 +131,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[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", @@ -110,7 +162,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement (left, right) => left.NodeId == right.NodeId, aliasLeft: "n", aliasRight: "d") - .Where(x => x.Key == key, "x"); + .Where(itemsFilter, "x"); if (filterMustBeIsDependency) @@ -154,6 +206,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[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", @@ -272,6 +325,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[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", @@ -342,6 +396,10 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement [Column("key")] public Guid Key { get; set; } + [Column("trashed")] public bool Trashed { get; set; } + + [Column("nodeObjectType")] public Guid NodeObjectType { get; set; } + [Column("otherKey")] public Guid OtherKey { get; set; } [Column("alias")] public string? Alias { get; set; } @@ -365,6 +423,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement RelationTypeName = dto.RelationTypeName, RelationTypeIsBidirectional = dto.RelationTypeIsBidirectional, RelationTypeIsDependency = dto.RelationTypeIsDependency, + ContentTypeKey = dto.ChildContentTypeKey, ContentTypeAlias = dto.ChildContentTypeAlias, ContentTypeIcon = dto.ChildContentTypeIcon, ContentTypeName = dto.ChildContentTypeName, diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs index c9ea02414b..8f2acf12dc 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs @@ -268,6 +268,10 @@ public abstract class BlockValuePropertyValueEditorBase : DataV TValue? mergedBlockValue = MergeVariantInvariantPropertyValueTyped(source, target, canUpdateInvariantData, allowedCultures); + if (mergedBlockValue is null) + { + return null; + } return _jsonSerializer.Serialize(mergedBlockValue); } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs index 9fe8dbe4ec..714d1e624b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs @@ -89,21 +89,6 @@ public class MultipleTextStringPropertyEditor : DataEditor return null; } - if (!(editorValue.DataTypeConfiguration is MultipleTextStringConfiguration config)) - { - throw new PanicException( - $"editorValue.DataTypeConfiguration is {editorValue.DataTypeConfiguration?.GetType()} but must be {typeof(MultipleTextStringConfiguration)}"); - } - - var max = config.Max; - - // The legacy property editor saved this data as new line delimited! strange but we have to maintain that. - // only allow the max if over 0 - if (max > 0) - { - return string.Join(_newLine, value.Take(max)); - } - return string.Join(_newLine, value); } @@ -114,9 +99,12 @@ public class MultipleTextStringPropertyEditor : DataEditor // The legacy property editor saved this data as new line delimited! strange but we have to maintain that. return value is string stringValue - ? stringValue.Split(_newLineDelimiters, StringSplitOptions.None) + ? SplitPropertyValue(stringValue) : Array.Empty(); } + + internal static string[] SplitPropertyValue(string propertyValue) + => propertyValue.Split(_newLineDelimiters, StringSplitOptions.None); } /// @@ -166,13 +154,13 @@ public class MultipleTextStringPropertyEditor : DataEditor yield break; } - // If we have a null value, treat as an empty collection for minimum number validation. - if (value is not IEnumerable stringValues) - { - stringValues = []; - } - - var stringCount = stringValues.Count(); + // Handle both a newline delimited string and an IEnumerable as the value (see: https://github.com/umbraco/Umbraco-CMS/pull/18936). + // If we have a null value, treat as a string count of zero for minimum number validation. + var stringCount = value is string stringValue + ? MultipleTextStringPropertyValueEditor.SplitPropertyValue(stringValue).Length + : value is IEnumerable strings + ? strings.Count() + : 0; if (stringCount < multipleTextStringConfiguration.Min) { diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs index 634641723b..c08f125955 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs @@ -63,7 +63,7 @@ public class BackOfficeIdentityUser : UmbracoIdentityUser get => _startContentIds; set { - value ??= new int[0]; + value ??= []; BeingDirty.SetPropertyValueAndDetectChanges(value, ref _startContentIds!, nameof(StartContentIds), _startIdsComparer); } diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 588aed7f63..e86d9dfc68 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -138,8 +138,8 @@ public class BackOfficeUserStore : var userEntity = new User(_globalSettings, user.Name, user.Email, user.UserName, emptyPasswordValue) { Language = user.Culture ?? _globalSettings.DefaultUILanguage, - StartContentIds = user.StartContentIds ?? new int[] { }, - StartMediaIds = user.StartMediaIds ?? new int[] { }, + StartContentIds = user.StartContentIds ?? [], + StartMediaIds = user.StartMediaIds ?? [], IsLockedOut = user.IsLockedOut, Key = user.Key, Kind = user.Kind diff --git a/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs b/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs index e6daf8b3b6..1823825a9f 100644 --- a/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs +++ b/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs @@ -2,11 +2,11 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.HybridCache.Persistence; namespace Umbraco.Cms.Infrastructure.HybridCache; diff --git a/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs b/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs index 255187babc..515d3b18d8 100644 --- a/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs +++ b/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Extensions; @@ -14,7 +15,7 @@ public class OAuthOptionsHelper { // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 // we omit "state" and "error_uri" here as it hold no value in determining the message to display to the user - private static readonly IReadOnlyCollection _oathCallbackErrorParams = new string[] { "error", "error_description" }; + private static readonly string[] _oathCallbackErrorParams = ["error", "error_description"]; private readonly IOptions _securitySettings; @@ -43,7 +44,7 @@ public class OAuthOptionsHelper SetUmbracoRedirectWithFilteredParams(context, providerFriendlyName, eventName) .HandleResponse(); - return Task.FromResult(0); + return Task.CompletedTask; } /// @@ -60,9 +61,9 @@ public class OAuthOptionsHelper foreach (var oathCallbackErrorParam in _oathCallbackErrorParams) { - if (context.Request.Query.ContainsKey(oathCallbackErrorParam)) + if (context.Request.Query.TryGetValue(oathCallbackErrorParam, out StringValues paramValue)) { - callbackPath = callbackPath.AppendQueryStringToUrl($"{oathCallbackErrorParam}={context.Request.Query[oathCallbackErrorParam]}"); + callbackPath = callbackPath.AppendQueryStringToUrl($"{oathCallbackErrorParam}={paramValue}"); } } diff --git a/src/Umbraco.Web.UI.Client/devops/circular/index.js b/src/Umbraco.Web.UI.Client/devops/circular/index.js index 5766a5e34c..01c427e5b3 100644 --- a/src/Umbraco.Web.UI.Client/devops/circular/index.js +++ b/src/Umbraco.Web.UI.Client/devops/circular/index.js @@ -10,6 +10,10 @@ import { join } from 'path'; //import { mkdirSync } from 'fs'; //const __dirname = import.meta.dirname; + +// Adjust this number as needed. +const MAX_CIRCULAR_DEPENDENCIES = 4; + const IS_GITHUB_ACTIONS = process.env.GITHUB_ACTIONS === 'true'; const IS_AZURE_PIPELINES = process.env.TF_BUILD === 'true'; const baseDir = process.argv[2] || 'src'; @@ -52,8 +56,6 @@ if (circular.length) { */ // TODO: Remove this check and set an exit with argument 1 when we have fixed all circular dependencies. - // The current threshold for circular dependencies is set to 5. Adjust this number as needed. - const MAX_CIRCULAR_DEPENDENCIES = 5; if (circular.length > MAX_CIRCULAR_DEPENDENCIES) { process.exit(1); } else if (circular.length < MAX_CIRCULAR_DEPENDENCIES) { diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index f1085c909a..6803ab7454 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -89,7 +89,7 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.24.1", "typescript-json-schema": "^0.65.1", - "vite": "^6.2.3", + "vite": "^6.2.5", "vite-plugin-static-copy": "^2.2.0", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" @@ -16864,9 +16864,9 @@ } }, "node_modules/vite": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz", - "integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", + "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 0a5589daa2..dd9d9b0a25 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -274,7 +274,7 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.24.1", "typescript-json-schema": "^0.65.1", - "vite": "^6.2.3", + "vite": "^6.2.5", "vite-plugin-static-copy": "^2.2.0", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index 5f12431c2b..3ed8031671 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -1589,6 +1589,7 @@ export default { '\n If mandatory, the child template must contain a @section definition, otherwise an error is shown.\n ', queryBuilder: 'Query builder', itemsReturned: 'items returned, in', + publishedItemsReturned: 'Currently %0% published items returned, in %1% ms', iWant: 'I want', allContent: 'all content', contentOfType: 'content of type "%0%"', @@ -2654,6 +2655,8 @@ export default { unsupportedBlockName: 'Unsupported', unsupportedBlockDescription: 'This content is no longer supported in this Editor. If you are missing this content, please contact your administrator. Otherwise delete it.', + blockVariantConfigurationNotSupported: + 'One or more Block Types of this Block Editor is using a Element-Type that is configured to Vary By Culture or Vary By Segment. This is not supported on a Content item that does not vary by Culture or Segment.', }, contentTemplatesDashboard: { whatHeadline: 'What are Document Blueprints?', diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts index 78a6e36b45..080a6477db 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { GetCultureData, GetCultureResponse, PostDataTypeData, PostDataTypeResponse, GetDataTypeByIdData, GetDataTypeByIdResponse, DeleteDataTypeByIdData, DeleteDataTypeByIdResponse, PutDataTypeByIdData, PutDataTypeByIdResponse, PostDataTypeByIdCopyData, PostDataTypeByIdCopyResponse, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedResponse, PutDataTypeByIdMoveData, PutDataTypeByIdMoveResponse, GetDataTypeByIdReferencesData, GetDataTypeByIdReferencesResponse, GetDataTypeConfigurationResponse, PostDataTypeFolderData, PostDataTypeFolderResponse, GetDataTypeFolderByIdData, GetDataTypeFolderByIdResponse, DeleteDataTypeFolderByIdData, DeleteDataTypeFolderByIdResponse, PutDataTypeFolderByIdData, PutDataTypeFolderByIdResponse, GetFilterDataTypeData, GetFilterDataTypeResponse, GetItemDataTypeData, GetItemDataTypeResponse, GetItemDataTypeSearchData, GetItemDataTypeSearchResponse, GetTreeDataTypeAncestorsData, GetTreeDataTypeAncestorsResponse, GetTreeDataTypeChildrenData, GetTreeDataTypeChildrenResponse, GetTreeDataTypeRootData, GetTreeDataTypeRootResponse, GetDictionaryData, GetDictionaryResponse, PostDictionaryData, PostDictionaryResponse, GetDictionaryByIdData, GetDictionaryByIdResponse, DeleteDictionaryByIdData, DeleteDictionaryByIdResponse, PutDictionaryByIdData, PutDictionaryByIdResponse, GetDictionaryByIdExportData, GetDictionaryByIdExportResponse, PutDictionaryByIdMoveData, PutDictionaryByIdMoveResponse, PostDictionaryImportData, PostDictionaryImportResponse, GetItemDictionaryData, GetItemDictionaryResponse, GetTreeDictionaryAncestorsData, GetTreeDictionaryAncestorsResponse, GetTreeDictionaryChildrenData, GetTreeDictionaryChildrenResponse, GetTreeDictionaryRootData, GetTreeDictionaryRootResponse, GetCollectionDocumentByIdData, GetCollectionDocumentByIdResponse, PostDocumentData, PostDocumentResponse, GetDocumentByIdData, GetDocumentByIdResponse, DeleteDocumentByIdData, DeleteDocumentByIdResponse, PutDocumentByIdData, PutDocumentByIdResponse, GetDocumentByIdAuditLogData, GetDocumentByIdAuditLogResponse, PostDocumentByIdCopyData, PostDocumentByIdCopyResponse, GetDocumentByIdDomainsData, GetDocumentByIdDomainsResponse, PutDocumentByIdDomainsData, PutDocumentByIdDomainsResponse, PutDocumentByIdMoveData, PutDocumentByIdMoveResponse, PutDocumentByIdMoveToRecycleBinData, PutDocumentByIdMoveToRecycleBinResponse, GetDocumentByIdNotificationsData, GetDocumentByIdNotificationsResponse, PutDocumentByIdNotificationsData, PutDocumentByIdNotificationsResponse, PostDocumentByIdPublicAccessData, PostDocumentByIdPublicAccessResponse, DeleteDocumentByIdPublicAccessData, DeleteDocumentByIdPublicAccessResponse, GetDocumentByIdPublicAccessData, GetDocumentByIdPublicAccessResponse, PutDocumentByIdPublicAccessData, PutDocumentByIdPublicAccessResponse, PutDocumentByIdPublishData, PutDocumentByIdPublishResponse, PutDocumentByIdPublishWithDescendantsData, PutDocumentByIdPublishWithDescendantsResponse, GetDocumentByIdPublishedData, GetDocumentByIdPublishedResponse, GetDocumentByIdReferencedByData, GetDocumentByIdReferencedByResponse, GetDocumentByIdReferencedDescendantsData, GetDocumentByIdReferencedDescendantsResponse, PutDocumentByIdUnpublishData, PutDocumentByIdUnpublishResponse, PutUmbracoManagementApiV11DocumentByIdValidate11Data, PutUmbracoManagementApiV11DocumentByIdValidate11Response, GetDocumentAreReferencedData, GetDocumentAreReferencedResponse, GetDocumentConfigurationResponse, PutDocumentSortData, PutDocumentSortResponse, GetDocumentUrlsData, GetDocumentUrlsResponse, PostDocumentValidateData, PostDocumentValidateResponse, GetItemDocumentData, GetItemDocumentResponse, GetItemDocumentSearchData, GetItemDocumentSearchResponse, DeleteRecycleBinDocumentResponse, DeleteRecycleBinDocumentByIdData, DeleteRecycleBinDocumentByIdResponse, GetRecycleBinDocumentByIdOriginalParentData, GetRecycleBinDocumentByIdOriginalParentResponse, PutRecycleBinDocumentByIdRestoreData, PutRecycleBinDocumentByIdRestoreResponse, GetRecycleBinDocumentChildrenData, GetRecycleBinDocumentChildrenResponse, GetRecycleBinDocumentRootData, GetRecycleBinDocumentRootResponse, GetTreeDocumentAncestorsData, GetTreeDocumentAncestorsResponse, GetTreeDocumentChildrenData, GetTreeDocumentChildrenResponse, GetTreeDocumentRootData, GetTreeDocumentRootResponse, PostDocumentBlueprintData, PostDocumentBlueprintResponse, GetDocumentBlueprintByIdData, GetDocumentBlueprintByIdResponse, DeleteDocumentBlueprintByIdData, DeleteDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdData, PutDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdMoveData, PutDocumentBlueprintByIdMoveResponse, PostDocumentBlueprintFolderData, PostDocumentBlueprintFolderResponse, GetDocumentBlueprintFolderByIdData, GetDocumentBlueprintFolderByIdResponse, DeleteDocumentBlueprintFolderByIdData, DeleteDocumentBlueprintFolderByIdResponse, PutDocumentBlueprintFolderByIdData, PutDocumentBlueprintFolderByIdResponse, PostDocumentBlueprintFromDocumentData, PostDocumentBlueprintFromDocumentResponse, GetItemDocumentBlueprintData, GetItemDocumentBlueprintResponse, GetTreeDocumentBlueprintAncestorsData, GetTreeDocumentBlueprintAncestorsResponse, GetTreeDocumentBlueprintChildrenData, GetTreeDocumentBlueprintChildrenResponse, GetTreeDocumentBlueprintRootData, GetTreeDocumentBlueprintRootResponse, PostDocumentTypeData, PostDocumentTypeResponse, GetDocumentTypeByIdData, GetDocumentTypeByIdResponse, DeleteDocumentTypeByIdData, DeleteDocumentTypeByIdResponse, PutDocumentTypeByIdData, PutDocumentTypeByIdResponse, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenResponse, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintResponse, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesResponse, PostDocumentTypeByIdCopyData, PostDocumentTypeByIdCopyResponse, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportResponse, PutDocumentTypeByIdImportData, PutDocumentTypeByIdImportResponse, PutDocumentTypeByIdMoveData, PutDocumentTypeByIdMoveResponse, GetDocumentTypeAllowedAtRootData, GetDocumentTypeAllowedAtRootResponse, PostDocumentTypeAvailableCompositionsData, PostDocumentTypeAvailableCompositionsResponse, GetDocumentTypeConfigurationResponse, PostDocumentTypeFolderData, PostDocumentTypeFolderResponse, GetDocumentTypeFolderByIdData, GetDocumentTypeFolderByIdResponse, DeleteDocumentTypeFolderByIdData, DeleteDocumentTypeFolderByIdResponse, PutDocumentTypeFolderByIdData, PutDocumentTypeFolderByIdResponse, PostDocumentTypeImportData, PostDocumentTypeImportResponse, GetItemDocumentTypeData, GetItemDocumentTypeResponse, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchResponse, GetTreeDocumentTypeAncestorsData, GetTreeDocumentTypeAncestorsResponse, GetTreeDocumentTypeChildrenData, GetTreeDocumentTypeChildrenResponse, GetTreeDocumentTypeRootData, GetTreeDocumentTypeRootResponse, GetDocumentVersionData, GetDocumentVersionResponse, GetDocumentVersionByIdData, GetDocumentVersionByIdResponse, PutDocumentVersionByIdPreventCleanupData, PutDocumentVersionByIdPreventCleanupResponse, PostDocumentVersionByIdRollbackData, PostDocumentVersionByIdRollbackResponse, PostDynamicRootQueryData, PostDynamicRootQueryResponse, GetDynamicRootStepsResponse, GetHealthCheckGroupData, GetHealthCheckGroupResponse, GetHealthCheckGroupByNameData, GetHealthCheckGroupByNameResponse, PostHealthCheckGroupByNameCheckData, PostHealthCheckGroupByNameCheckResponse, PostHealthCheckExecuteActionData, PostHealthCheckExecuteActionResponse, GetHelpData, GetHelpResponse, GetImagingResizeUrlsData, GetImagingResizeUrlsResponse, GetImportAnalyzeData, GetImportAnalyzeResponse, GetIndexerData, GetIndexerResponse, GetIndexerByIndexNameData, GetIndexerByIndexNameResponse, PostIndexerByIndexNameRebuildData, PostIndexerByIndexNameRebuildResponse, GetInstallSettingsResponse, PostInstallSetupData, PostInstallSetupResponse, PostInstallValidateDatabaseData, PostInstallValidateDatabaseResponse, GetItemLanguageData, GetItemLanguageResponse, GetItemLanguageDefaultResponse, GetLanguageData, GetLanguageResponse, PostLanguageData, PostLanguageResponse, GetLanguageByIsoCodeData, GetLanguageByIsoCodeResponse, DeleteLanguageByIsoCodeData, DeleteLanguageByIsoCodeResponse, PutLanguageByIsoCodeData, PutLanguageByIsoCodeResponse, GetLogViewerLevelData, GetLogViewerLevelResponse, GetLogViewerLevelCountData, GetLogViewerLevelCountResponse, GetLogViewerLogData, GetLogViewerLogResponse, GetLogViewerMessageTemplateData, GetLogViewerMessageTemplateResponse, GetLogViewerSavedSearchData, GetLogViewerSavedSearchResponse, PostLogViewerSavedSearchData, PostLogViewerSavedSearchResponse, GetLogViewerSavedSearchByNameData, GetLogViewerSavedSearchByNameResponse, DeleteLogViewerSavedSearchByNameData, DeleteLogViewerSavedSearchByNameResponse, GetLogViewerValidateLogsSizeData, GetLogViewerValidateLogsSizeResponse, GetManifestManifestResponse, GetManifestManifestPrivateResponse, GetManifestManifestPublicResponse, GetCollectionMediaData, GetCollectionMediaResponse, GetItemMediaData, GetItemMediaResponse, GetItemMediaSearchData, GetItemMediaSearchResponse, PostMediaData, PostMediaResponse, GetMediaByIdData, GetMediaByIdResponse, DeleteMediaByIdData, DeleteMediaByIdResponse, PutMediaByIdData, PutMediaByIdResponse, GetMediaByIdAuditLogData, GetMediaByIdAuditLogResponse, PutMediaByIdMoveData, PutMediaByIdMoveResponse, PutMediaByIdMoveToRecycleBinData, PutMediaByIdMoveToRecycleBinResponse, GetMediaByIdReferencedByData, GetMediaByIdReferencedByResponse, GetMediaByIdReferencedDescendantsData, GetMediaByIdReferencedDescendantsResponse, PutMediaByIdValidateData, PutMediaByIdValidateResponse, GetMediaAreReferencedData, GetMediaAreReferencedResponse, GetMediaConfigurationResponse, PutMediaSortData, PutMediaSortResponse, GetMediaUrlsData, GetMediaUrlsResponse, PostMediaValidateData, PostMediaValidateResponse, DeleteRecycleBinMediaResponse, DeleteRecycleBinMediaByIdData, DeleteRecycleBinMediaByIdResponse, GetRecycleBinMediaByIdOriginalParentData, GetRecycleBinMediaByIdOriginalParentResponse, PutRecycleBinMediaByIdRestoreData, PutRecycleBinMediaByIdRestoreResponse, GetRecycleBinMediaChildrenData, GetRecycleBinMediaChildrenResponse, GetRecycleBinMediaRootData, GetRecycleBinMediaRootResponse, GetTreeMediaAncestorsData, GetTreeMediaAncestorsResponse, GetTreeMediaChildrenData, GetTreeMediaChildrenResponse, GetTreeMediaRootData, GetTreeMediaRootResponse, GetItemMediaTypeData, GetItemMediaTypeResponse, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedResponse, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersResponse, GetItemMediaTypeSearchData, GetItemMediaTypeSearchResponse, PostMediaTypeData, PostMediaTypeResponse, GetMediaTypeByIdData, GetMediaTypeByIdResponse, DeleteMediaTypeByIdData, DeleteMediaTypeByIdResponse, PutMediaTypeByIdData, PutMediaTypeByIdResponse, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenResponse, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesResponse, PostMediaTypeByIdCopyData, PostMediaTypeByIdCopyResponse, GetMediaTypeByIdExportData, GetMediaTypeByIdExportResponse, PutMediaTypeByIdImportData, PutMediaTypeByIdImportResponse, PutMediaTypeByIdMoveData, PutMediaTypeByIdMoveResponse, GetMediaTypeAllowedAtRootData, GetMediaTypeAllowedAtRootResponse, PostMediaTypeAvailableCompositionsData, PostMediaTypeAvailableCompositionsResponse, GetMediaTypeConfigurationResponse, PostMediaTypeFolderData, PostMediaTypeFolderResponse, GetMediaTypeFolderByIdData, GetMediaTypeFolderByIdResponse, DeleteMediaTypeFolderByIdData, DeleteMediaTypeFolderByIdResponse, PutMediaTypeFolderByIdData, PutMediaTypeFolderByIdResponse, PostMediaTypeImportData, PostMediaTypeImportResponse, GetTreeMediaTypeAncestorsData, GetTreeMediaTypeAncestorsResponse, GetTreeMediaTypeChildrenData, GetTreeMediaTypeChildrenResponse, GetTreeMediaTypeRootData, GetTreeMediaTypeRootResponse, GetFilterMemberData, GetFilterMemberResponse, GetItemMemberData, GetItemMemberResponse, GetItemMemberSearchData, GetItemMemberSearchResponse, PostMemberData, PostMemberResponse, GetMemberByIdData, GetMemberByIdResponse, DeleteMemberByIdData, DeleteMemberByIdResponse, PutMemberByIdData, PutMemberByIdResponse, GetMemberByIdReferencedByData, GetMemberByIdReferencedByResponse, GetMemberByIdReferencedDescendantsData, GetMemberByIdReferencedDescendantsResponse, PutMemberByIdValidateData, PutMemberByIdValidateResponse, GetMemberAreReferencedData, GetMemberAreReferencedResponse, GetMemberConfigurationResponse, PostMemberValidateData, PostMemberValidateResponse, GetItemMemberGroupData, GetItemMemberGroupResponse, GetMemberGroupData, GetMemberGroupResponse, PostMemberGroupData, PostMemberGroupResponse, GetMemberGroupByIdData, GetMemberGroupByIdResponse, DeleteMemberGroupByIdData, DeleteMemberGroupByIdResponse, PutMemberGroupByIdData, PutMemberGroupByIdResponse, GetTreeMemberGroupRootData, GetTreeMemberGroupRootResponse, GetItemMemberTypeData, GetItemMemberTypeResponse, GetItemMemberTypeSearchData, GetItemMemberTypeSearchResponse, PostMemberTypeData, PostMemberTypeResponse, GetMemberTypeByIdData, GetMemberTypeByIdResponse, DeleteMemberTypeByIdData, DeleteMemberTypeByIdResponse, PutMemberTypeByIdData, PutMemberTypeByIdResponse, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesResponse, PostMemberTypeByIdCopyData, PostMemberTypeByIdCopyResponse, PostMemberTypeAvailableCompositionsData, PostMemberTypeAvailableCompositionsResponse, GetMemberTypeConfigurationResponse, GetTreeMemberTypeRootData, GetTreeMemberTypeRootResponse, PostModelsBuilderBuildResponse, GetModelsBuilderDashboardResponse, GetModelsBuilderStatusResponse, GetObjectTypesData, GetObjectTypesResponse, GetOembedQueryData, GetOembedQueryResponse, PostPackageByNameRunMigrationData, PostPackageByNameRunMigrationResponse, GetPackageConfigurationResponse, GetPackageCreatedData, GetPackageCreatedResponse, PostPackageCreatedData, PostPackageCreatedResponse, GetPackageCreatedByIdData, GetPackageCreatedByIdResponse, DeletePackageCreatedByIdData, DeletePackageCreatedByIdResponse, PutPackageCreatedByIdData, PutPackageCreatedByIdResponse, GetPackageCreatedByIdDownloadData, GetPackageCreatedByIdDownloadResponse, GetPackageMigrationStatusData, GetPackageMigrationStatusResponse, GetItemPartialViewData, GetItemPartialViewResponse, PostPartialViewData, PostPartialViewResponse, GetPartialViewByPathData, GetPartialViewByPathResponse, DeletePartialViewByPathData, DeletePartialViewByPathResponse, PutPartialViewByPathData, PutPartialViewByPathResponse, PutPartialViewByPathRenameData, PutPartialViewByPathRenameResponse, PostPartialViewFolderData, PostPartialViewFolderResponse, GetPartialViewFolderByPathData, GetPartialViewFolderByPathResponse, DeletePartialViewFolderByPathData, DeletePartialViewFolderByPathResponse, GetPartialViewSnippetData, GetPartialViewSnippetResponse, GetPartialViewSnippetByIdData, GetPartialViewSnippetByIdResponse, GetTreePartialViewAncestorsData, GetTreePartialViewAncestorsResponse, GetTreePartialViewChildrenData, GetTreePartialViewChildrenResponse, GetTreePartialViewRootData, GetTreePartialViewRootResponse, DeletePreviewResponse, PostPreviewResponse, GetProfilingStatusResponse, PutProfilingStatusData, PutProfilingStatusResponse, GetPropertyTypeIsUsedData, GetPropertyTypeIsUsedResponse, PostPublishedCacheRebuildResponse, GetPublishedCacheRebuildStatusResponse, PostPublishedCacheReloadResponse, GetRedirectManagementData, GetRedirectManagementResponse, GetRedirectManagementByIdData, GetRedirectManagementByIdResponse, DeleteRedirectManagementByIdData, DeleteRedirectManagementByIdResponse, GetRedirectManagementStatusResponse, PostRedirectManagementStatusData, PostRedirectManagementStatusResponse, GetRelationByRelationTypeIdData, GetRelationByRelationTypeIdResponse, GetItemRelationTypeData, GetItemRelationTypeResponse, GetRelationTypeData, GetRelationTypeResponse, GetRelationTypeByIdData, GetRelationTypeByIdResponse, GetItemScriptData, GetItemScriptResponse, PostScriptData, PostScriptResponse, GetScriptByPathData, GetScriptByPathResponse, DeleteScriptByPathData, DeleteScriptByPathResponse, PutScriptByPathData, PutScriptByPathResponse, PutScriptByPathRenameData, PutScriptByPathRenameResponse, PostScriptFolderData, PostScriptFolderResponse, GetScriptFolderByPathData, GetScriptFolderByPathResponse, DeleteScriptFolderByPathData, DeleteScriptFolderByPathResponse, GetTreeScriptAncestorsData, GetTreeScriptAncestorsResponse, GetTreeScriptChildrenData, GetTreeScriptChildrenResponse, GetTreeScriptRootData, GetTreeScriptRootResponse, GetSearcherData, GetSearcherResponse, GetSearcherBySearcherNameQueryData, GetSearcherBySearcherNameQueryResponse, GetSecurityConfigurationResponse, PostSecurityForgotPasswordData, PostSecurityForgotPasswordResponse, PostSecurityForgotPasswordResetData, PostSecurityForgotPasswordResetResponse, PostSecurityForgotPasswordVerifyData, PostSecurityForgotPasswordVerifyResponse, GetSegmentData, GetSegmentResponse, GetServerConfigurationResponse, GetServerInformationResponse, GetServerStatusResponse, GetServerTroubleshootingResponse, GetServerUpgradeCheckResponse, GetItemStaticFileData, GetItemStaticFileResponse, GetTreeStaticFileAncestorsData, GetTreeStaticFileAncestorsResponse, GetTreeStaticFileChildrenData, GetTreeStaticFileChildrenResponse, GetTreeStaticFileRootData, GetTreeStaticFileRootResponse, GetItemStylesheetData, GetItemStylesheetResponse, PostStylesheetData, PostStylesheetResponse, GetStylesheetByPathData, GetStylesheetByPathResponse, DeleteStylesheetByPathData, DeleteStylesheetByPathResponse, PutStylesheetByPathData, PutStylesheetByPathResponse, PutStylesheetByPathRenameData, PutStylesheetByPathRenameResponse, PostStylesheetFolderData, PostStylesheetFolderResponse, GetStylesheetFolderByPathData, GetStylesheetFolderByPathResponse, DeleteStylesheetFolderByPathData, DeleteStylesheetFolderByPathResponse, GetTreeStylesheetAncestorsData, GetTreeStylesheetAncestorsResponse, GetTreeStylesheetChildrenData, GetTreeStylesheetChildrenResponse, GetTreeStylesheetRootData, GetTreeStylesheetRootResponse, GetTagData, GetTagResponse, GetTelemetryData, GetTelemetryResponse, GetTelemetryLevelResponse, PostTelemetryLevelData, PostTelemetryLevelResponse, GetItemTemplateData, GetItemTemplateResponse, GetItemTemplateSearchData, GetItemTemplateSearchResponse, PostTemplateData, PostTemplateResponse, GetTemplateByIdData, GetTemplateByIdResponse, DeleteTemplateByIdData, DeleteTemplateByIdResponse, PutTemplateByIdData, PutTemplateByIdResponse, GetTemplateConfigurationResponse, PostTemplateQueryExecuteData, PostTemplateQueryExecuteResponse, GetTemplateQuerySettingsResponse, GetTreeTemplateAncestorsData, GetTreeTemplateAncestorsResponse, GetTreeTemplateChildrenData, GetTreeTemplateChildrenResponse, GetTreeTemplateRootData, GetTreeTemplateRootResponse, PostTemporaryFileData, PostTemporaryFileResponse, GetTemporaryFileByIdData, GetTemporaryFileByIdResponse, DeleteTemporaryFileByIdData, DeleteTemporaryFileByIdResponse, GetTemporaryFileConfigurationResponse, PostUpgradeAuthorizeResponse, GetUpgradeSettingsResponse, GetFilterUserData, GetFilterUserResponse, GetItemUserData, GetItemUserResponse, PostUserData, PostUserResponse, DeleteUserData, DeleteUserResponse, GetUserData, GetUserResponse, GetUserByIdData, GetUserByIdResponse, DeleteUserByIdData, DeleteUserByIdResponse, PutUserByIdData, PutUserByIdResponse, GetUserById2FaData, GetUserById2FaResponse, DeleteUserById2FaByProviderNameData, DeleteUserById2FaByProviderNameResponse, GetUserByIdCalculateStartNodesData, GetUserByIdCalculateStartNodesResponse, PostUserByIdChangePasswordData, PostUserByIdChangePasswordResponse, PostUserByIdClientCredentialsData, PostUserByIdClientCredentialsResponse, GetUserByIdClientCredentialsData, GetUserByIdClientCredentialsResponse, DeleteUserByIdClientCredentialsByClientIdData, DeleteUserByIdClientCredentialsByClientIdResponse, PostUserByIdResetPasswordData, PostUserByIdResetPasswordResponse, DeleteUserAvatarByIdData, DeleteUserAvatarByIdResponse, PostUserAvatarByIdData, PostUserAvatarByIdResponse, GetUserConfigurationResponse, GetUserCurrentResponse, GetUserCurrent2FaResponse, DeleteUserCurrent2FaByProviderNameData, DeleteUserCurrent2FaByProviderNameResponse, PostUserCurrent2FaByProviderNameData, PostUserCurrent2FaByProviderNameResponse, GetUserCurrent2FaByProviderNameData, GetUserCurrent2FaByProviderNameResponse, PostUserCurrentAvatarData, PostUserCurrentAvatarResponse, PostUserCurrentChangePasswordData, PostUserCurrentChangePasswordResponse, GetUserCurrentConfigurationResponse, GetUserCurrentLoginProvidersResponse, GetUserCurrentPermissionsData, GetUserCurrentPermissionsResponse, GetUserCurrentPermissionsDocumentData, GetUserCurrentPermissionsDocumentResponse, GetUserCurrentPermissionsMediaData, GetUserCurrentPermissionsMediaResponse, PostUserDisableData, PostUserDisableResponse, PostUserEnableData, PostUserEnableResponse, PostUserInviteData, PostUserInviteResponse, PostUserInviteCreatePasswordData, PostUserInviteCreatePasswordResponse, PostUserInviteResendData, PostUserInviteResendResponse, PostUserInviteVerifyData, PostUserInviteVerifyResponse, PostUserSetUserGroupsData, PostUserSetUserGroupsResponse, PostUserUnlockData, PostUserUnlockResponse, PostUserDataData, PostUserDataResponse, GetUserDataData, GetUserDataResponse, PutUserDataData, PutUserDataResponse, GetUserDataByIdData, GetUserDataByIdResponse, GetFilterUserGroupData, GetFilterUserGroupResponse, GetItemUserGroupData, GetItemUserGroupResponse, DeleteUserGroupData, DeleteUserGroupResponse, PostUserGroupData, PostUserGroupResponse, GetUserGroupData, GetUserGroupResponse, GetUserGroupByIdData, GetUserGroupByIdResponse, DeleteUserGroupByIdData, DeleteUserGroupByIdResponse, PutUserGroupByIdData, PutUserGroupByIdResponse, DeleteUserGroupByIdUsersData, DeleteUserGroupByIdUsersResponse, PostUserGroupByIdUsersData, PostUserGroupByIdUsersResponse, GetItemWebhookData, GetItemWebhookResponse, GetWebhookData, GetWebhookResponse, PostWebhookData, PostWebhookResponse, GetWebhookByIdData, GetWebhookByIdResponse, DeleteWebhookByIdData, DeleteWebhookByIdResponse, PutWebhookByIdData, PutWebhookByIdResponse, GetWebhookByIdLogsData, GetWebhookByIdLogsResponse, GetWebhookEventsData, GetWebhookEventsResponse, GetWebhookLogsData, GetWebhookLogsResponse } from './types.gen'; +import type { GetCultureData, GetCultureResponse, PostDataTypeData, PostDataTypeResponse, GetDataTypeByIdData, GetDataTypeByIdResponse, DeleteDataTypeByIdData, DeleteDataTypeByIdResponse, PutDataTypeByIdData, PutDataTypeByIdResponse, PostDataTypeByIdCopyData, PostDataTypeByIdCopyResponse, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedResponse, PutDataTypeByIdMoveData, PutDataTypeByIdMoveResponse, GetDataTypeByIdReferencedByData, GetDataTypeByIdReferencedByResponse, GetDataTypeByIdReferencesData, GetDataTypeByIdReferencesResponse, GetDataTypeConfigurationResponse, PostDataTypeFolderData, PostDataTypeFolderResponse, GetDataTypeFolderByIdData, GetDataTypeFolderByIdResponse, DeleteDataTypeFolderByIdData, DeleteDataTypeFolderByIdResponse, PutDataTypeFolderByIdData, PutDataTypeFolderByIdResponse, GetFilterDataTypeData, GetFilterDataTypeResponse, GetItemDataTypeData, GetItemDataTypeResponse, GetItemDataTypeSearchData, GetItemDataTypeSearchResponse, GetTreeDataTypeAncestorsData, GetTreeDataTypeAncestorsResponse, GetTreeDataTypeChildrenData, GetTreeDataTypeChildrenResponse, GetTreeDataTypeRootData, GetTreeDataTypeRootResponse, GetDictionaryData, GetDictionaryResponse, PostDictionaryData, PostDictionaryResponse, GetDictionaryByIdData, GetDictionaryByIdResponse, DeleteDictionaryByIdData, DeleteDictionaryByIdResponse, PutDictionaryByIdData, PutDictionaryByIdResponse, GetDictionaryByIdExportData, GetDictionaryByIdExportResponse, PutDictionaryByIdMoveData, PutDictionaryByIdMoveResponse, PostDictionaryImportData, PostDictionaryImportResponse, GetItemDictionaryData, GetItemDictionaryResponse, GetTreeDictionaryAncestorsData, GetTreeDictionaryAncestorsResponse, GetTreeDictionaryChildrenData, GetTreeDictionaryChildrenResponse, GetTreeDictionaryRootData, GetTreeDictionaryRootResponse, GetCollectionDocumentByIdData, GetCollectionDocumentByIdResponse, PostDocumentData, PostDocumentResponse, GetDocumentByIdData, GetDocumentByIdResponse, DeleteDocumentByIdData, DeleteDocumentByIdResponse, PutDocumentByIdData, PutDocumentByIdResponse, GetDocumentByIdAuditLogData, GetDocumentByIdAuditLogResponse, PostDocumentByIdCopyData, PostDocumentByIdCopyResponse, GetDocumentByIdDomainsData, GetDocumentByIdDomainsResponse, PutDocumentByIdDomainsData, PutDocumentByIdDomainsResponse, PutDocumentByIdMoveData, PutDocumentByIdMoveResponse, PutDocumentByIdMoveToRecycleBinData, PutDocumentByIdMoveToRecycleBinResponse, GetDocumentByIdNotificationsData, GetDocumentByIdNotificationsResponse, PutDocumentByIdNotificationsData, PutDocumentByIdNotificationsResponse, PostDocumentByIdPublicAccessData, PostDocumentByIdPublicAccessResponse, DeleteDocumentByIdPublicAccessData, DeleteDocumentByIdPublicAccessResponse, GetDocumentByIdPublicAccessData, GetDocumentByIdPublicAccessResponse, PutDocumentByIdPublicAccessData, PutDocumentByIdPublicAccessResponse, PutDocumentByIdPublishData, PutDocumentByIdPublishResponse, PutDocumentByIdPublishWithDescendantsData, PutDocumentByIdPublishWithDescendantsResponse, GetDocumentByIdPublishWithDescendantsResultByTaskIdData, GetDocumentByIdPublishWithDescendantsResultByTaskIdResponse, GetDocumentByIdPublishedData, GetDocumentByIdPublishedResponse, GetDocumentByIdReferencedByData, GetDocumentByIdReferencedByResponse, GetDocumentByIdReferencedDescendantsData, GetDocumentByIdReferencedDescendantsResponse, PutDocumentByIdUnpublishData, PutDocumentByIdUnpublishResponse, PutUmbracoManagementApiV11DocumentByIdValidate11Data, PutUmbracoManagementApiV11DocumentByIdValidate11Response, GetDocumentAreReferencedData, GetDocumentAreReferencedResponse, GetDocumentConfigurationResponse, PutDocumentSortData, PutDocumentSortResponse, GetDocumentUrlsData, GetDocumentUrlsResponse, PostDocumentValidateData, PostDocumentValidateResponse, GetItemDocumentData, GetItemDocumentResponse, GetItemDocumentSearchData, GetItemDocumentSearchResponse, DeleteRecycleBinDocumentResponse, DeleteRecycleBinDocumentByIdData, DeleteRecycleBinDocumentByIdResponse, GetRecycleBinDocumentByIdOriginalParentData, GetRecycleBinDocumentByIdOriginalParentResponse, PutRecycleBinDocumentByIdRestoreData, PutRecycleBinDocumentByIdRestoreResponse, GetRecycleBinDocumentChildrenData, GetRecycleBinDocumentChildrenResponse, GetRecycleBinDocumentReferencedByData, GetRecycleBinDocumentReferencedByResponse, GetRecycleBinDocumentRootData, GetRecycleBinDocumentRootResponse, GetTreeDocumentAncestorsData, GetTreeDocumentAncestorsResponse, GetTreeDocumentChildrenData, GetTreeDocumentChildrenResponse, GetTreeDocumentRootData, GetTreeDocumentRootResponse, PostDocumentBlueprintData, PostDocumentBlueprintResponse, GetDocumentBlueprintByIdData, GetDocumentBlueprintByIdResponse, DeleteDocumentBlueprintByIdData, DeleteDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdData, PutDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdMoveData, PutDocumentBlueprintByIdMoveResponse, PostDocumentBlueprintFolderData, PostDocumentBlueprintFolderResponse, GetDocumentBlueprintFolderByIdData, GetDocumentBlueprintFolderByIdResponse, DeleteDocumentBlueprintFolderByIdData, DeleteDocumentBlueprintFolderByIdResponse, PutDocumentBlueprintFolderByIdData, PutDocumentBlueprintFolderByIdResponse, PostDocumentBlueprintFromDocumentData, PostDocumentBlueprintFromDocumentResponse, GetItemDocumentBlueprintData, GetItemDocumentBlueprintResponse, GetTreeDocumentBlueprintAncestorsData, GetTreeDocumentBlueprintAncestorsResponse, GetTreeDocumentBlueprintChildrenData, GetTreeDocumentBlueprintChildrenResponse, GetTreeDocumentBlueprintRootData, GetTreeDocumentBlueprintRootResponse, PostDocumentTypeData, PostDocumentTypeResponse, GetDocumentTypeByIdData, GetDocumentTypeByIdResponse, DeleteDocumentTypeByIdData, DeleteDocumentTypeByIdResponse, PutDocumentTypeByIdData, PutDocumentTypeByIdResponse, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenResponse, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintResponse, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesResponse, PostDocumentTypeByIdCopyData, PostDocumentTypeByIdCopyResponse, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportResponse, PutDocumentTypeByIdImportData, PutDocumentTypeByIdImportResponse, PutDocumentTypeByIdMoveData, PutDocumentTypeByIdMoveResponse, GetDocumentTypeAllowedAtRootData, GetDocumentTypeAllowedAtRootResponse, PostDocumentTypeAvailableCompositionsData, PostDocumentTypeAvailableCompositionsResponse, GetDocumentTypeConfigurationResponse, PostDocumentTypeFolderData, PostDocumentTypeFolderResponse, GetDocumentTypeFolderByIdData, GetDocumentTypeFolderByIdResponse, DeleteDocumentTypeFolderByIdData, DeleteDocumentTypeFolderByIdResponse, PutDocumentTypeFolderByIdData, PutDocumentTypeFolderByIdResponse, PostDocumentTypeImportData, PostDocumentTypeImportResponse, GetItemDocumentTypeData, GetItemDocumentTypeResponse, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchResponse, GetTreeDocumentTypeAncestorsData, GetTreeDocumentTypeAncestorsResponse, GetTreeDocumentTypeChildrenData, GetTreeDocumentTypeChildrenResponse, GetTreeDocumentTypeRootData, GetTreeDocumentTypeRootResponse, GetDocumentVersionData, GetDocumentVersionResponse, GetDocumentVersionByIdData, GetDocumentVersionByIdResponse, PutDocumentVersionByIdPreventCleanupData, PutDocumentVersionByIdPreventCleanupResponse, PostDocumentVersionByIdRollbackData, PostDocumentVersionByIdRollbackResponse, PostDynamicRootQueryData, PostDynamicRootQueryResponse, GetDynamicRootStepsResponse, GetHealthCheckGroupData, GetHealthCheckGroupResponse, GetHealthCheckGroupByNameData, GetHealthCheckGroupByNameResponse, PostHealthCheckGroupByNameCheckData, PostHealthCheckGroupByNameCheckResponse, PostHealthCheckExecuteActionData, PostHealthCheckExecuteActionResponse, GetHelpData, GetHelpResponse, GetImagingResizeUrlsData, GetImagingResizeUrlsResponse, GetImportAnalyzeData, GetImportAnalyzeResponse, GetIndexerData, GetIndexerResponse, GetIndexerByIndexNameData, GetIndexerByIndexNameResponse, PostIndexerByIndexNameRebuildData, PostIndexerByIndexNameRebuildResponse, GetInstallSettingsResponse, PostInstallSetupData, PostInstallSetupResponse, PostInstallValidateDatabaseData, PostInstallValidateDatabaseResponse, GetItemLanguageData, GetItemLanguageResponse, GetItemLanguageDefaultResponse, GetLanguageData, GetLanguageResponse, PostLanguageData, PostLanguageResponse, GetLanguageByIsoCodeData, GetLanguageByIsoCodeResponse, DeleteLanguageByIsoCodeData, DeleteLanguageByIsoCodeResponse, PutLanguageByIsoCodeData, PutLanguageByIsoCodeResponse, GetLogViewerLevelData, GetLogViewerLevelResponse, GetLogViewerLevelCountData, GetLogViewerLevelCountResponse, GetLogViewerLogData, GetLogViewerLogResponse, GetLogViewerMessageTemplateData, GetLogViewerMessageTemplateResponse, GetLogViewerSavedSearchData, GetLogViewerSavedSearchResponse, PostLogViewerSavedSearchData, PostLogViewerSavedSearchResponse, GetLogViewerSavedSearchByNameData, GetLogViewerSavedSearchByNameResponse, DeleteLogViewerSavedSearchByNameData, DeleteLogViewerSavedSearchByNameResponse, GetLogViewerValidateLogsSizeData, GetLogViewerValidateLogsSizeResponse, GetManifestManifestResponse, GetManifestManifestPrivateResponse, GetManifestManifestPublicResponse, GetCollectionMediaData, GetCollectionMediaResponse, GetItemMediaData, GetItemMediaResponse, GetItemMediaSearchData, GetItemMediaSearchResponse, PostMediaData, PostMediaResponse, GetMediaByIdData, GetMediaByIdResponse, DeleteMediaByIdData, DeleteMediaByIdResponse, PutMediaByIdData, PutMediaByIdResponse, GetMediaByIdAuditLogData, GetMediaByIdAuditLogResponse, PutMediaByIdMoveData, PutMediaByIdMoveResponse, PutMediaByIdMoveToRecycleBinData, PutMediaByIdMoveToRecycleBinResponse, GetMediaByIdReferencedByData, GetMediaByIdReferencedByResponse, GetMediaByIdReferencedDescendantsData, GetMediaByIdReferencedDescendantsResponse, PutMediaByIdValidateData, PutMediaByIdValidateResponse, GetMediaAreReferencedData, GetMediaAreReferencedResponse, GetMediaConfigurationResponse, PutMediaSortData, PutMediaSortResponse, GetMediaUrlsData, GetMediaUrlsResponse, PostMediaValidateData, PostMediaValidateResponse, DeleteRecycleBinMediaResponse, DeleteRecycleBinMediaByIdData, DeleteRecycleBinMediaByIdResponse, GetRecycleBinMediaByIdOriginalParentData, GetRecycleBinMediaByIdOriginalParentResponse, PutRecycleBinMediaByIdRestoreData, PutRecycleBinMediaByIdRestoreResponse, GetRecycleBinMediaChildrenData, GetRecycleBinMediaChildrenResponse, GetRecycleBinMediaReferencedByData, GetRecycleBinMediaReferencedByResponse, GetRecycleBinMediaRootData, GetRecycleBinMediaRootResponse, GetTreeMediaAncestorsData, GetTreeMediaAncestorsResponse, GetTreeMediaChildrenData, GetTreeMediaChildrenResponse, GetTreeMediaRootData, GetTreeMediaRootResponse, GetItemMediaTypeData, GetItemMediaTypeResponse, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedResponse, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersResponse, GetItemMediaTypeSearchData, GetItemMediaTypeSearchResponse, PostMediaTypeData, PostMediaTypeResponse, GetMediaTypeByIdData, GetMediaTypeByIdResponse, DeleteMediaTypeByIdData, DeleteMediaTypeByIdResponse, PutMediaTypeByIdData, PutMediaTypeByIdResponse, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenResponse, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesResponse, PostMediaTypeByIdCopyData, PostMediaTypeByIdCopyResponse, GetMediaTypeByIdExportData, GetMediaTypeByIdExportResponse, PutMediaTypeByIdImportData, PutMediaTypeByIdImportResponse, PutMediaTypeByIdMoveData, PutMediaTypeByIdMoveResponse, GetMediaTypeAllowedAtRootData, GetMediaTypeAllowedAtRootResponse, PostMediaTypeAvailableCompositionsData, PostMediaTypeAvailableCompositionsResponse, GetMediaTypeConfigurationResponse, PostMediaTypeFolderData, PostMediaTypeFolderResponse, GetMediaTypeFolderByIdData, GetMediaTypeFolderByIdResponse, DeleteMediaTypeFolderByIdData, DeleteMediaTypeFolderByIdResponse, PutMediaTypeFolderByIdData, PutMediaTypeFolderByIdResponse, PostMediaTypeImportData, PostMediaTypeImportResponse, GetTreeMediaTypeAncestorsData, GetTreeMediaTypeAncestorsResponse, GetTreeMediaTypeChildrenData, GetTreeMediaTypeChildrenResponse, GetTreeMediaTypeRootData, GetTreeMediaTypeRootResponse, GetFilterMemberData, GetFilterMemberResponse, GetItemMemberData, GetItemMemberResponse, GetItemMemberSearchData, GetItemMemberSearchResponse, PostMemberData, PostMemberResponse, GetMemberByIdData, GetMemberByIdResponse, DeleteMemberByIdData, DeleteMemberByIdResponse, PutMemberByIdData, PutMemberByIdResponse, GetMemberByIdReferencedByData, GetMemberByIdReferencedByResponse, GetMemberByIdReferencedDescendantsData, GetMemberByIdReferencedDescendantsResponse, PutMemberByIdValidateData, PutMemberByIdValidateResponse, GetMemberAreReferencedData, GetMemberAreReferencedResponse, GetMemberConfigurationResponse, PostMemberValidateData, PostMemberValidateResponse, GetItemMemberGroupData, GetItemMemberGroupResponse, GetMemberGroupData, GetMemberGroupResponse, PostMemberGroupData, PostMemberGroupResponse, GetMemberGroupByIdData, GetMemberGroupByIdResponse, DeleteMemberGroupByIdData, DeleteMemberGroupByIdResponse, PutMemberGroupByIdData, PutMemberGroupByIdResponse, GetTreeMemberGroupRootData, GetTreeMemberGroupRootResponse, GetItemMemberTypeData, GetItemMemberTypeResponse, GetItemMemberTypeSearchData, GetItemMemberTypeSearchResponse, PostMemberTypeData, PostMemberTypeResponse, GetMemberTypeByIdData, GetMemberTypeByIdResponse, DeleteMemberTypeByIdData, DeleteMemberTypeByIdResponse, PutMemberTypeByIdData, PutMemberTypeByIdResponse, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesResponse, PostMemberTypeByIdCopyData, PostMemberTypeByIdCopyResponse, PostMemberTypeAvailableCompositionsData, PostMemberTypeAvailableCompositionsResponse, GetMemberTypeConfigurationResponse, GetTreeMemberTypeRootData, GetTreeMemberTypeRootResponse, PostModelsBuilderBuildResponse, GetModelsBuilderDashboardResponse, GetModelsBuilderStatusResponse, GetObjectTypesData, GetObjectTypesResponse, GetOembedQueryData, GetOembedQueryResponse, PostPackageByNameRunMigrationData, PostPackageByNameRunMigrationResponse, GetPackageConfigurationResponse, GetPackageCreatedData, GetPackageCreatedResponse, PostPackageCreatedData, PostPackageCreatedResponse, GetPackageCreatedByIdData, GetPackageCreatedByIdResponse, DeletePackageCreatedByIdData, DeletePackageCreatedByIdResponse, PutPackageCreatedByIdData, PutPackageCreatedByIdResponse, GetPackageCreatedByIdDownloadData, GetPackageCreatedByIdDownloadResponse, GetPackageMigrationStatusData, GetPackageMigrationStatusResponse, GetItemPartialViewData, GetItemPartialViewResponse, PostPartialViewData, PostPartialViewResponse, GetPartialViewByPathData, GetPartialViewByPathResponse, DeletePartialViewByPathData, DeletePartialViewByPathResponse, PutPartialViewByPathData, PutPartialViewByPathResponse, PutPartialViewByPathRenameData, PutPartialViewByPathRenameResponse, PostPartialViewFolderData, PostPartialViewFolderResponse, GetPartialViewFolderByPathData, GetPartialViewFolderByPathResponse, DeletePartialViewFolderByPathData, DeletePartialViewFolderByPathResponse, GetPartialViewSnippetData, GetPartialViewSnippetResponse, GetPartialViewSnippetByIdData, GetPartialViewSnippetByIdResponse, GetTreePartialViewAncestorsData, GetTreePartialViewAncestorsResponse, GetTreePartialViewChildrenData, GetTreePartialViewChildrenResponse, GetTreePartialViewRootData, GetTreePartialViewRootResponse, DeletePreviewResponse, PostPreviewResponse, GetProfilingStatusResponse, PutProfilingStatusData, PutProfilingStatusResponse, GetPropertyTypeIsUsedData, GetPropertyTypeIsUsedResponse, PostPublishedCacheRebuildResponse, GetPublishedCacheRebuildStatusResponse, PostPublishedCacheReloadResponse, GetRedirectManagementData, GetRedirectManagementResponse, GetRedirectManagementByIdData, GetRedirectManagementByIdResponse, DeleteRedirectManagementByIdData, DeleteRedirectManagementByIdResponse, GetRedirectManagementStatusResponse, PostRedirectManagementStatusData, PostRedirectManagementStatusResponse, GetRelationByRelationTypeIdData, GetRelationByRelationTypeIdResponse, GetItemRelationTypeData, GetItemRelationTypeResponse, GetRelationTypeData, GetRelationTypeResponse, GetRelationTypeByIdData, GetRelationTypeByIdResponse, GetItemScriptData, GetItemScriptResponse, PostScriptData, PostScriptResponse, GetScriptByPathData, GetScriptByPathResponse, DeleteScriptByPathData, DeleteScriptByPathResponse, PutScriptByPathData, PutScriptByPathResponse, PutScriptByPathRenameData, PutScriptByPathRenameResponse, PostScriptFolderData, PostScriptFolderResponse, GetScriptFolderByPathData, GetScriptFolderByPathResponse, DeleteScriptFolderByPathData, DeleteScriptFolderByPathResponse, GetTreeScriptAncestorsData, GetTreeScriptAncestorsResponse, GetTreeScriptChildrenData, GetTreeScriptChildrenResponse, GetTreeScriptRootData, GetTreeScriptRootResponse, GetSearcherData, GetSearcherResponse, GetSearcherBySearcherNameQueryData, GetSearcherBySearcherNameQueryResponse, GetSecurityConfigurationResponse, PostSecurityForgotPasswordData, PostSecurityForgotPasswordResponse, PostSecurityForgotPasswordResetData, PostSecurityForgotPasswordResetResponse, PostSecurityForgotPasswordVerifyData, PostSecurityForgotPasswordVerifyResponse, GetSegmentData, GetSegmentResponse, GetServerConfigurationResponse, GetServerInformationResponse, GetServerStatusResponse, GetServerTroubleshootingResponse, GetServerUpgradeCheckResponse, GetItemStaticFileData, GetItemStaticFileResponse, GetTreeStaticFileAncestorsData, GetTreeStaticFileAncestorsResponse, GetTreeStaticFileChildrenData, GetTreeStaticFileChildrenResponse, GetTreeStaticFileRootData, GetTreeStaticFileRootResponse, GetItemStylesheetData, GetItemStylesheetResponse, PostStylesheetData, PostStylesheetResponse, GetStylesheetByPathData, GetStylesheetByPathResponse, DeleteStylesheetByPathData, DeleteStylesheetByPathResponse, PutStylesheetByPathData, PutStylesheetByPathResponse, PutStylesheetByPathRenameData, PutStylesheetByPathRenameResponse, PostStylesheetFolderData, PostStylesheetFolderResponse, GetStylesheetFolderByPathData, GetStylesheetFolderByPathResponse, DeleteStylesheetFolderByPathData, DeleteStylesheetFolderByPathResponse, GetTreeStylesheetAncestorsData, GetTreeStylesheetAncestorsResponse, GetTreeStylesheetChildrenData, GetTreeStylesheetChildrenResponse, GetTreeStylesheetRootData, GetTreeStylesheetRootResponse, GetTagData, GetTagResponse, GetTelemetryData, GetTelemetryResponse, GetTelemetryLevelResponse, PostTelemetryLevelData, PostTelemetryLevelResponse, GetItemTemplateData, GetItemTemplateResponse, GetItemTemplateSearchData, GetItemTemplateSearchResponse, PostTemplateData, PostTemplateResponse, GetTemplateByIdData, GetTemplateByIdResponse, DeleteTemplateByIdData, DeleteTemplateByIdResponse, PutTemplateByIdData, PutTemplateByIdResponse, GetTemplateConfigurationResponse, PostTemplateQueryExecuteData, PostTemplateQueryExecuteResponse, GetTemplateQuerySettingsResponse, GetTreeTemplateAncestorsData, GetTreeTemplateAncestorsResponse, GetTreeTemplateChildrenData, GetTreeTemplateChildrenResponse, GetTreeTemplateRootData, GetTreeTemplateRootResponse, PostTemporaryFileData, PostTemporaryFileResponse, GetTemporaryFileByIdData, GetTemporaryFileByIdResponse, DeleteTemporaryFileByIdData, DeleteTemporaryFileByIdResponse, GetTemporaryFileConfigurationResponse, PostUpgradeAuthorizeResponse, GetUpgradeSettingsResponse, GetFilterUserData, GetFilterUserResponse, GetItemUserData, GetItemUserResponse, PostUserData, PostUserResponse, DeleteUserData, DeleteUserResponse, GetUserData, GetUserResponse, GetUserByIdData, GetUserByIdResponse, DeleteUserByIdData, DeleteUserByIdResponse, PutUserByIdData, PutUserByIdResponse, GetUserById2FaData, GetUserById2FaResponse, DeleteUserById2FaByProviderNameData, DeleteUserById2FaByProviderNameResponse, GetUserByIdCalculateStartNodesData, GetUserByIdCalculateStartNodesResponse, PostUserByIdChangePasswordData, PostUserByIdChangePasswordResponse, PostUserByIdClientCredentialsData, PostUserByIdClientCredentialsResponse, GetUserByIdClientCredentialsData, GetUserByIdClientCredentialsResponse, DeleteUserByIdClientCredentialsByClientIdData, DeleteUserByIdClientCredentialsByClientIdResponse, PostUserByIdResetPasswordData, PostUserByIdResetPasswordResponse, DeleteUserAvatarByIdData, DeleteUserAvatarByIdResponse, PostUserAvatarByIdData, PostUserAvatarByIdResponse, GetUserConfigurationResponse, GetUserCurrentResponse, GetUserCurrent2FaResponse, DeleteUserCurrent2FaByProviderNameData, DeleteUserCurrent2FaByProviderNameResponse, PostUserCurrent2FaByProviderNameData, PostUserCurrent2FaByProviderNameResponse, GetUserCurrent2FaByProviderNameData, GetUserCurrent2FaByProviderNameResponse, PostUserCurrentAvatarData, PostUserCurrentAvatarResponse, PostUserCurrentChangePasswordData, PostUserCurrentChangePasswordResponse, GetUserCurrentConfigurationResponse, GetUserCurrentLoginProvidersResponse, GetUserCurrentPermissionsData, GetUserCurrentPermissionsResponse, GetUserCurrentPermissionsDocumentData, GetUserCurrentPermissionsDocumentResponse, GetUserCurrentPermissionsMediaData, GetUserCurrentPermissionsMediaResponse, PostUserDisableData, PostUserDisableResponse, PostUserEnableData, PostUserEnableResponse, PostUserInviteData, PostUserInviteResponse, PostUserInviteCreatePasswordData, PostUserInviteCreatePasswordResponse, PostUserInviteResendData, PostUserInviteResendResponse, PostUserInviteVerifyData, PostUserInviteVerifyResponse, PostUserSetUserGroupsData, PostUserSetUserGroupsResponse, PostUserUnlockData, PostUserUnlockResponse, PostUserDataData, PostUserDataResponse, GetUserDataData, GetUserDataResponse, PutUserDataData, PutUserDataResponse, GetUserDataByIdData, GetUserDataByIdResponse, GetFilterUserGroupData, GetFilterUserGroupResponse, GetItemUserGroupData, GetItemUserGroupResponse, DeleteUserGroupData, DeleteUserGroupResponse, PostUserGroupData, PostUserGroupResponse, GetUserGroupData, GetUserGroupResponse, GetUserGroupByIdData, GetUserGroupByIdResponse, DeleteUserGroupByIdData, DeleteUserGroupByIdResponse, PutUserGroupByIdData, PutUserGroupByIdResponse, DeleteUserGroupByIdUsersData, DeleteUserGroupByIdUsersResponse, PostUserGroupByIdUsersData, PostUserGroupByIdUsersResponse, GetItemWebhookData, GetItemWebhookResponse, GetWebhookData, GetWebhookResponse, PostWebhookData, PostWebhookResponse, GetWebhookByIdData, GetWebhookByIdResponse, DeleteWebhookByIdData, DeleteWebhookByIdResponse, PutWebhookByIdData, PutWebhookByIdResponse, GetWebhookByIdLogsData, GetWebhookByIdLogsResponse, GetWebhookEventsData, GetWebhookEventsResponse, GetWebhookLogsData, GetWebhookLogsResponse } from './types.gen'; export class CultureService { /** @@ -194,6 +194,33 @@ export class DataTypeService { } /** + * @param data The data for the request. + * @param data.id + * @param data.skip + * @param data.take + * @returns unknown OK + * @throws ApiError + */ + public static getDataTypeByIdReferencedBy(data: GetDataTypeByIdReferencedByData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/data-type/{id}/referenced-by', + path: { + id: data.id + }, + query: { + skip: data.skip, + take: data.take + }, + errors: { + 401: 'The resource is protected and requires an authentication token', + 403: 'The authenticated user does not have access to this resource' + } + }); + } + + /** + * @deprecated * @param data The data for the request. * @param data.id * @returns unknown OK @@ -1192,7 +1219,7 @@ export class DocumentService { * @param data The data for the request. * @param data.id * @param data.requestBody - * @returns string OK + * @returns unknown OK * @throws ApiError */ public static putDocumentByIdPublishWithDescendants(data: PutDocumentByIdPublishWithDescendantsData): CancelablePromise { @@ -1204,7 +1231,30 @@ export class DocumentService { }, body: data.requestBody, mediaType: 'application/json', - responseHeader: 'Umb-Notifications', + errors: { + 400: 'Bad Request', + 401: 'The resource is protected and requires an authentication token', + 403: 'The authenticated user does not have access to this resource', + 404: 'Not Found' + } + }); + } + + /** + * @param data The data for the request. + * @param data.id + * @param data.taskId + * @returns unknown OK + * @throws ApiError + */ + public static getDocumentByIdPublishWithDescendantsResultByTaskId(data: GetDocumentByIdPublishWithDescendantsResultByTaskIdData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/document/{id}/publish-with-descendants/result/{taskId}', + path: { + id: data.id, + taskId: data.taskId + }, errors: { 400: 'Bad Request', 401: 'The resource is protected and requires an authentication token', @@ -1602,6 +1652,28 @@ export class DocumentService { }); } + /** + * @param data The data for the request. + * @param data.skip + * @param data.take + * @returns unknown OK + * @throws ApiError + */ + public static getRecycleBinDocumentReferencedBy(data: GetRecycleBinDocumentReferencedByData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/recycle-bin/document/referenced-by', + query: { + skip: data.skip, + take: data.take + }, + errors: { + 401: 'The resource is protected and requires an authentication token', + 403: 'The authenticated user does not have access to this resource' + } + }); + } + /** * @param data The data for the request. * @param data.skip @@ -3965,6 +4037,28 @@ export class MediaService { }); } + /** + * @param data The data for the request. + * @param data.skip + * @param data.take + * @returns unknown OK + * @throws ApiError + */ + public static getRecycleBinMediaReferencedBy(data: GetRecycleBinMediaReferencedByData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/recycle-bin/media/referenced-by', + query: { + skip: data.skip, + take: data.take + }, + errors: { + 401: 'The resource is protected and requires an authentication token', + 403: 'The authenticated user does not have access to this resource' + } + }); + } + /** * @param data The data for the request. * @param data.skip diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts index 3a826a3c86..24763b7109 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts @@ -639,6 +639,7 @@ export type DocumentCollectionResponseModel = { documentType: (DocumentTypeCollectionReferenceResponseModel); isTrashed: boolean; isProtected: boolean; + ancestors: Array<(ReferenceByIdModel)>; updater?: (string) | null; }; @@ -707,6 +708,7 @@ export type DocumentTreeItemResponseModel = { id: string; createDate: string; isProtected: boolean; + ancestors: Array<(ReferenceByIdModel)>; documentType: (DocumentTypeReferenceResponseModel); variants: Array<(DocumentVariantItemResponseModel)>; }; @@ -769,6 +771,14 @@ export type DocumentTypePropertyTypeContainerResponseModel = { sortOrder: number; }; +export type DocumentTypePropertyTypeReferenceResponseModel = { + $type: string; + id: string; + name?: (string) | null; + alias?: (string) | null; + documentType: (TrackedReferenceDocumentTypeModel); +}; + export type DocumentTypePropertyTypeResponseModel = { id: string; container?: ((ReferenceByIdModel) | null); @@ -1273,6 +1283,14 @@ export type MediaTypePropertyTypeContainerResponseModel = { sortOrder: number; }; +export type MediaTypePropertyTypeReferenceResponseModel = { + $type: string; + id: string; + name?: (string) | null; + alias?: (string) | null; + mediaType: (TrackedReferenceMediaTypeModel); +}; + export type MediaTypePropertyTypeResponseModel = { id: string; container?: ((ReferenceByIdModel) | null); @@ -1452,6 +1470,14 @@ export type MemberTypePropertyTypeContainerResponseModel = { sortOrder: number; }; +export type MemberTypePropertyTypeReferenceResponseModel = { + $type: string; + id: string; + name?: (string) | null; + alias?: (string) | null; + memberType: (TrackedReferenceMemberTypeModel); +}; + export type MemberTypePropertyTypeResponseModel = { id: string; container?: ((ReferenceByIdModel) | null); @@ -1744,7 +1770,7 @@ export type PagedIndexResponseModel = { export type PagedIReferenceResponseModel = { total: number; - items: Array<(DefaultReferenceResponseModel | DocumentReferenceResponseModel | MediaReferenceResponseModel | MemberReferenceResponseModel)>; + items: Array<(DefaultReferenceResponseModel | DocumentReferenceResponseModel | DocumentTypePropertyTypeReferenceResponseModel | MediaReferenceResponseModel | MediaTypePropertyTypeReferenceResponseModel | MemberReferenceResponseModel | MemberTypePropertyTypeReferenceResponseModel)>; }; export type PagedLanguageResponseModel = { @@ -2056,6 +2082,11 @@ export type PublishedDocumentResponseModel = { isTrashed: boolean; }; +export type PublishWithDescendantsResultModel = { + taskId: string; + isComplete: boolean; +}; + export type RebuildStatusModel = { isRebuilding: boolean; }; @@ -2389,18 +2420,21 @@ export type TemporaryFileResponseModel = { }; export type TrackedReferenceDocumentTypeModel = { + id: string; icon?: (string) | null; alias?: (string) | null; name?: (string) | null; }; export type TrackedReferenceMediaTypeModel = { + id: string; icon?: (string) | null; alias?: (string) | null; name?: (string) | null; }; export type TrackedReferenceMemberTypeModel = { + id: string; icon?: (string) | null; alias?: (string) | null; name?: (string) | null; @@ -2979,6 +3013,14 @@ export type PutDataTypeByIdMoveData = { export type PutDataTypeByIdMoveResponse = (string); +export type GetDataTypeByIdReferencedByData = { + id: string; + skip?: number; + take?: number; +}; + +export type GetDataTypeByIdReferencedByResponse = ((PagedIReferenceResponseModel)); + export type GetDataTypeByIdReferencesData = { id: string; }; @@ -3271,7 +3313,14 @@ export type PutDocumentByIdPublishWithDescendantsData = { requestBody?: (PublishDocumentWithDescendantsRequestModel); }; -export type PutDocumentByIdPublishWithDescendantsResponse = (string); +export type PutDocumentByIdPublishWithDescendantsResponse = ((PublishWithDescendantsResultModel)); + +export type GetDocumentByIdPublishWithDescendantsResultByTaskIdData = { + id: string; + taskId: string; +}; + +export type GetDocumentByIdPublishWithDescendantsResultByTaskIdResponse = ((PublishWithDescendantsResultModel)); export type GetDocumentByIdPublishedData = { id: string; @@ -3383,6 +3432,13 @@ export type GetRecycleBinDocumentChildrenData = { export type GetRecycleBinDocumentChildrenResponse = ((PagedDocumentRecycleBinItemResponseModel)); +export type GetRecycleBinDocumentReferencedByData = { + skip?: number; + take?: number; +}; + +export type GetRecycleBinDocumentReferencedByResponse = ((PagedIReferenceResponseModel)); + export type GetRecycleBinDocumentRootData = { skip?: number; take?: number; @@ -4054,6 +4110,13 @@ export type GetRecycleBinMediaChildrenData = { export type GetRecycleBinMediaChildrenResponse = ((PagedMediaRecycleBinItemResponseModel)); +export type GetRecycleBinMediaReferencedByData = { + skip?: number; + take?: number; +}; + +export type GetRecycleBinMediaReferencedByResponse = ((PagedIReferenceResponseModel)); + export type GetRecycleBinMediaRootData = { skip?: number; take?: number; diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts index 86563b88c6..e7855d4288 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts @@ -23,10 +23,10 @@ export const Div = Node.create({ }, parseHTML() { - return [{ tag: 'div' }]; + return [{ tag: this.name }]; }, renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + return [this.name, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; }, }); diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts index b86b6ecc31..011a8209c6 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts @@ -52,4 +52,48 @@ export const HtmlGlobalAttributes = Extension.create + ({ commands }) => { + if (!className) return false; + const types = type ? [type] : this.options.types; + return types + .map((type) => commands.updateAttributes(type, { class: className })) + .every((response) => response); + }, + unsetClassName: + (type) => + ({ commands }) => { + const types = type ? [type] : this.options.types; + return types.map((type) => commands.resetAttributes(type, 'class')).every((response) => response); + }, + setId: + (id, type) => + ({ commands }) => { + if (!id) return false; + const types = type ? [type] : this.options.types; + return types.map((type) => commands.updateAttributes(type, { id })).every((response) => response); + }, + unsetId: + (type) => + ({ commands }) => { + const types = type ? [type] : this.options.types; + return types.map((type) => commands.resetAttributes(type, 'id')).every((response) => response); + }, + }; + }, }); + +declare module '@tiptap/core' { + interface Commands { + htmlGlobalAttributes: { + setClassName: (className?: string, type?: string) => ReturnType; + unsetClassName: (type?: string) => ReturnType; + setId: (id?: string, type?: string) => ReturnType; + unsetId: (type?: string) => ReturnType; + }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts index a4a375f100..6d49c46461 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts @@ -28,7 +28,7 @@ export const Span = Mark.create({ return { setSpanStyle: (styles) => - ({ commands, editor, chain }) => { + ({ commands, editor }) => { if (!styles) return false; const existing = editor.getAttributes(this.name)?.style as string; diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/json-string-comparison.function.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/json-string-comparison.function.ts index 7f0a1a008b..43529e6190 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/json-string-comparison.function.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/json-string-comparison.function.ts @@ -8,5 +8,5 @@ * Meaning no class instances can take part in this data. */ export function jsonStringComparison(a: unknown, b: unknown): boolean { - return JSON.stringify(a) === JSON.stringify(b); + return (a === undefined && b === undefined) || JSON.stringify(a) === JSON.stringify(b); } 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 8eadfbca21..d2a1eb9fe8 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 @@ -6,6 +6,7 @@ export interface UmbMockDocumentBlueprintModel extends UmbMockDocumentModel {} export const data: Array = [ { + ancestors: [], urls: [ { culture: 'en-US', 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 611ae9584e..6d6eec6720 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 @@ -34,6 +34,7 @@ const treeItemMapper = (model: UmbMockDocumentBlueprintModel): Omit = [ { + ancestors: [], urls: [ { culture: 'en-US', @@ -49,6 +50,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [ { culture: 'en-US', @@ -602,6 +604,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [ { culture: 'en-US', @@ -741,6 +744,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [], template: null, id: 'fd56a0b5-01a0-4da2-b428-52773bfa9cc4', @@ -825,6 +829,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [ { culture: 'en-US', @@ -873,6 +878,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [ { culture: 'en-US', @@ -951,6 +957,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [ { culture: 'en-US', 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 4e91ff2f96..8888af52be 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 @@ -57,6 +57,7 @@ const treeItemMapper = (model: UmbMockDocumentModel): DocumentTreeItemResponseMo 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, @@ -79,6 +80,7 @@ const createMockDocumentMapper = (request: CreateDocumentRequestModel): UmbMockD const now = new Date().toString(); return { + ancestors: [], documentType: { id: documentType.id, icon: documentType.icon, @@ -138,6 +140,7 @@ const itemMapper = (model: UmbMockDocumentModel): DocumentItemResponseModel => { const collectionMapper = (model: UmbMockDocumentModel): DocumentCollectionResponseModel => { return { + ancestors: model.ancestors, creator: null, documentType: { id: model.documentType.id, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts index 1137b79c47..5b79b027e3 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts @@ -6,7 +6,10 @@ import type { } from '@umbraco-cms/backoffice/external/backend-api'; export const items: Array< - DefaultReferenceResponseModel | DocumentReferenceResponseModel | MediaReferenceResponseModel | MemberReferenceResponseModel + | DefaultReferenceResponseModel + | DocumentReferenceResponseModel + | MediaReferenceResponseModel + | MemberReferenceResponseModel > = [ { $type: 'DocumentReferenceResponseModel', @@ -17,8 +20,9 @@ export const items: Array< alias: 'blogPost', icon: 'icon-document', name: 'Simple Document Type', + id: 'simple-document-type-id', }, - variants: [] + variants: [], } satisfies DocumentReferenceResponseModel, { $type: 'DocumentReferenceResponseModel', @@ -29,8 +33,9 @@ export const items: Array< alias: 'imageBlock', icon: 'icon-settings', name: 'Image Block', + id: 'image-block-id', }, - variants: [] + variants: [], } satisfies DocumentReferenceResponseModel, { $type: 'MediaReferenceResponseModel', @@ -40,6 +45,7 @@ export const items: Array< alias: 'image', icon: 'icon-picture', name: 'Image', + id: 'media-type-id', }, } satisfies MediaReferenceResponseModel, { diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts index b156099843..3434ca2286 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts @@ -9,6 +9,7 @@ import { css, type PropertyValueMap, ref, + nothing, } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { @@ -24,6 +25,7 @@ import { debounceTime } from '@umbraco-cms/backoffice/external/rxjs'; // TODO: consider moving the components to the property editor folder as they are only used here import '../../local-components.js'; +import { UMB_CONTENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content'; /** * @element umb-property-editor-ui-block-grid @@ -85,9 +87,51 @@ export class UmbPropertyEditorUIBlockGridElement return super.value; } + @state() + _notSupportedVariantSetting?: boolean; + constructor() { super(); + this.consumeContext(UMB_CONTENT_WORKSPACE_CONTEXT, (context) => { + this.observe( + observeMultiple([ + this.#managerContext.blockTypes, + context.structure.variesByCulture, + context.structure.variesBySegment, + ]), + async ([blockTypes, variesByCulture, variesBySegment]) => { + if (blockTypes.length > 0 && (variesByCulture === false || variesBySegment === false)) { + // check if any of the Blocks varyByCulture or Segment and then display a warning. + const promises = await Promise.all( + blockTypes.map(async (blockType) => { + const elementType = blockType.contentElementTypeKey; + await this.#managerContext.contentTypesLoaded; + const structure = await this.#managerContext.getStructure(elementType); + if (variesByCulture === false && structure?.getVariesByCulture() === true) { + // If block varies by culture but document does not. + return true; + } else if (variesBySegment === false && structure?.getVariesBySegment() === true) { + // If block varies by segment but document does not. + return true; + } + return false; + }), + ); + this._notSupportedVariantSetting = promises.filter((x) => x === true).length > 0; + + if (this._notSupportedVariantSetting) { + this.#validationContext.messages.addMessage( + 'config', + '$', + '#blockEditor_blockVariantConfigurationNotSupported', + ); + } + } + }, + ); + }).passContextAliasMatches(); + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { this.observe( context.dataPath, @@ -195,6 +239,9 @@ export class UmbPropertyEditorUIBlockGridElement } override render() { + if (this._notSupportedVariantSetting) { + return nothing; + } return html` = { getUniqueOfElement: (element) => { @@ -110,6 +111,8 @@ export class UmbPropertyEditorUIBlockListElement this.#managerContext.contentTypesLoaded.then(() => { const firstContentTypeName = this.#managerContext.getContentTypeNameOf(blocks[0].contentElementTypeKey); this._createButtonLabel = this.localize.term('blockEditor_addThis', this.localize.string(firstContentTypeName)); + + // If we are in a invariant context: }); } } @@ -157,9 +160,52 @@ export class UmbPropertyEditorUIBlockListElement readonly #managerContext = new UmbBlockListManagerContext(this); readonly #entriesContext = new UmbBlockListEntriesContext(this); + @state() + _notSupportedVariantSetting?: boolean; + constructor() { super(); + this.consumeContext(UMB_CONTENT_WORKSPACE_CONTEXT, (context) => { + this.observe( + observeMultiple([ + this.#managerContext.blockTypes, + context.structure.variesByCulture, + context.structure.variesBySegment, + ]), + async ([blockTypes, variesByCulture, variesBySegment]) => { + if (blockTypes.length > 0 && (variesByCulture === false || variesBySegment === false)) { + // check if any of the Blocks varyByCulture or Segment and then display a warning. + const promises = await Promise.all( + blockTypes.map(async (blockType) => { + const elementType = blockType.contentElementTypeKey; + await this.#managerContext.contentTypesLoaded; + const structure = await this.#managerContext.getStructure(elementType); + if (variesByCulture === false && structure?.getVariesByCulture() === true) { + // If block varies by culture but document does not. + return true; + } else if (variesBySegment === false && structure?.getVariesBySegment() === true) { + // If block varies by segment but document does not. + return true; + } + return false; + }), + ); + this._notSupportedVariantSetting = promises.filter((x) => x === true).length > 0; + + if (this._notSupportedVariantSetting) { + this.#validationContext.messages.addMessage( + 'config', + '$', + '#blockEditor_blockVariantConfigurationNotSupported', + 'blockConfigurationNotSupported', + ); + } + } + }, + ); + }).passContextAliasMatches(); + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { this.#gotPropertyContext(context); }); @@ -193,7 +239,7 @@ export class UmbPropertyEditorUIBlockListElement null, ); - this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => { + this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, async (context) => { this.#managerContext.setVariantId(context.getVariantId()); }); @@ -334,6 +380,9 @@ export class UmbPropertyEditorUIBlockListElement } override render() { + if (this._notSupportedVariantSetting) { + return nothing; + } return html` ${repeat( this._layouts, diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts index 5c18b09b68..028a3ed887 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts @@ -119,6 +119,18 @@ export class UmbBlockElementManager { + const newUrl = e.detail.url; + + if (this.#allowNavigateAway) { + return true; + } + + if (this._checkWillNavigateAway(newUrl) && this.getHasUnpersistedChanges()) { + /* Since ours modals are async while events are synchronous, we need to prevent the default behavior of the event, even if the modal hasn’t been resolved yet. + Once the modal is resolved (the user accepted to discard the changes and navigate away from the route), we will push a new history state. + This push will make the "willchangestate" event happen again and due to this somewhat "backward" behavior, + we set an "allowNavigateAway"-flag to prevent the "discard-changes" functionality from running in a loop.*/ + e.preventDefault(); + const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT).catch(() => undefined); + const modal = modalManager?.open(this, UMB_DISCARD_CHANGES_MODAL); + if (modal) { + try { + // navigate to the new url when discarding changes + await modal.onSubmit(); + this.#allowNavigateAway = true; + history.pushState({}, '', e.detail.url); + return true; + } catch { + return false; + } + } else { + console.error('No modal manager found!'); + } + } + + return true; + }; + + /** + * Check if the workspace is about to navigate away. + * @protected + * @param {string} newUrl The new url that the workspace is navigating to. + * @returns { boolean} true if the workspace is navigating away. + * @memberof UmbEntityWorkspaceContextBase + */ + protected _checkWillNavigateAway(newUrl: string): boolean { + return !newUrl.includes(this.routes.getActiveLocalPath()); + } + setEditorSize(editorSize: UUIModalSidebarSize) { this.#modalContext?.setModalSize(editorSize); } @@ -236,6 +283,7 @@ export class UmbBlockWorkspaceContext 0) { routes.push({ + unique: fallbackView.alias, path: '', component: () => createExtensionElement(fallbackView), setup: () => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/content-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/content-detail-workspace-base.ts index 745d239977..384e57c94c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/content-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/content-detail-workspace-base.ts @@ -283,7 +283,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< await this.structure.loadType((data as any)[this.#contentTypePropertyName].unique); // Set culture and segment for all values: - const cutlures = this.#languages.getValue().map((x) => x.unique); + const cultures = this.#languages.getValue().map((x) => x.unique); if (this.structure.variesBySegment) { console.warn('Segments are not yet implemented for preset'); @@ -319,11 +319,28 @@ export abstract class UmbContentDetailWorkspaceContextBase< ); const controller = new UmbPropertyValuePresetVariantBuilderController(this); - controller.setCultures(cutlures); + controller.setCultures(cultures); if (segments) { controller.setSegments(segments); } - data.values = await controller.create(valueDefinitions); + + const presetValues = await controller.create(valueDefinitions); + + // Don't just set the values, as we could have some already populated from a blueprint. + // If we have a value from both a blueprint and a preset, use the latter as priority. + const dataValues = [...data.values]; + for (let index = 0; index < presetValues.length; index++) { + const presetValue = presetValues[index]; + const variantId = UmbVariantId.Create(presetValue); + const matchingDataValueIndex = dataValues.findIndex((v) => v.alias === presetValue.alias && variantId.compare(v)); + if (matchingDataValueIndex > -1) { + dataValues[matchingDataValueIndex] = presetValue; + } else { + dataValues.push(presetValue); + } + } + + data.values = dataValues; return data; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json index d4badf6fb5..5675562abc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json @@ -986,6 +986,10 @@ "name": "icon-heading-3", "file": "heading-3.svg" }, + { + "name": "icon-heading-4", + "file": "heading-4.svg" + }, { "name": "icon-headphones", "file": "headphones.svg" @@ -1507,6 +1511,10 @@ "name": "icon-partly-cloudy", "file": "cloud-sun.svg" }, + { + "name": "icon-paragraph", + "file": "pilcrow.svg" + }, { "name": "icon-paste-in", "file": "clipboard-paste.svg", diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-picker-modal/icon-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-picker-modal/icon-picker-modal.element.ts index 83ebe7904d..ece11d0ff9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-picker-modal/icon-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-picker-modal/icon-picker-modal.element.ts @@ -1,5 +1,3 @@ -import { UMB_ICON_REGISTRY_CONTEXT } from '../icon-registry.context-token.js'; -import type { UmbIconDefinition } from '../types.js'; import type { UmbIconPickerModalData, UmbIconPickerModalValue } from './icon-picker-modal.token.js'; import { css, customElement, html, nothing, query, repeat, state } from '@umbraco-cms/backoffice/external/lit'; import { extractUmbColorVariable, umbracoColors } from '@umbraco-cms/backoffice/resources'; @@ -7,6 +5,8 @@ import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UUIColorSwatchesEvent } from '@umbraco-cms/backoffice/external/uui'; +import type { UmbIconDefinition } from '../types.js'; +import { UMB_ICON_REGISTRY_CONTEXT } from '../icon-registry.context-token.js'; @customElement('umb-icon-picker-modal') export class UmbIconPickerModalElement extends UmbModalBaseElement { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts index 03eb3c6d1b..6efeae5314 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts @@ -775,6 +775,9 @@ path: () => import("./icons/icon-heading-2.js"), name: "icon-heading-3", path: () => import("./icons/icon-heading-3.js"), },{ +name: "icon-heading-4", +path: () => import("./icons/icon-heading-4.js"), +},{ name: "icon-headphones", path: () => import("./icons/icon-headphones.js"), },{ @@ -1217,6 +1220,9 @@ path: () => import("./icons/icon-paper-plane.js"), name: "icon-partly-cloudy", path: () => import("./icons/icon-partly-cloudy.js"), },{ +name: "icon-paragraph", +path: () => import("./icons/icon-paragraph.js"), +},{ name: "icon-paste-in", legacy: true, hidden: true, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-4.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-4.ts new file mode 100644 index 0000000000..765f9c988a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-4.ts @@ -0,0 +1 @@ +export default ``; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-paragraph.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-paragraph.ts new file mode 100644 index 0000000000..e22a3a37e7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-paragraph.ts @@ -0,0 +1 @@ +export default ``; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts index 9313a8de31..1096ec181e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts @@ -95,6 +95,7 @@ export class UmbModalElement extends UmbLitElement { this.#modalRouterElement = document.createElement('umb-router-slot'); this.#modalRouterElement.routes = [ { + unique: '_umbEmptyRoute_', path: '', component: document.createElement('slot'), }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.interface.ts deleted file mode 100644 index 235df6c9f6..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.interface.ts +++ /dev/null @@ -1 +0,0 @@ -export type { IRoute as UmbRoute } from '../../router-slot/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/index.ts index de29580c1d..5323ea8417 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/index.ts @@ -1,5 +1,4 @@ -export * from './components/not-found/route-not-found.element.js'; -export * from './components/router-slot/index.js'; +export * from './route/index.js'; export * from './contexts/index.js'; export * from './encode-folder-name.function.js'; export * from './modal-registration/modal-route-registration.controller.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/modal-registration/modal-route-registration.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/modal-registration/modal-route-registration.controller.ts index b5e66c4a27..8595b95f84 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/modal-registration/modal-route-registration.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/modal-registration/modal-route-registration.controller.ts @@ -1,5 +1,6 @@ -import { UMB_ROUTE_CONTEXT, UMB_ROUTE_PATH_ADDENDUM_CONTEXT } from '../index.js'; import type { IRouterSlot, Params } from '../router-slot/index.js'; +import { UMB_ROUTE_PATH_ADDENDUM_CONTEXT } from '../contexts/route-path-addendum.context-token.js'; +import { UMB_ROUTE_CONTEXT } from '../route/route.context.js'; import { encodeFolderName } from '../encode-folder-name.function.js'; import type { UmbModalRouteRegistration } from './modal-route-registration.interface.js'; import type { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/index.ts similarity index 79% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/index.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/index.ts index ed32035358..1c30607927 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/index.ts @@ -1,3 +1,4 @@ +export * from './not-found/route-not-found.element.js'; export * from './route.context.js'; export * from './router-slot-change.event.js'; export * from './router-slot-init.event.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/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 similarity index 95% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/not-found/route-not-found.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/not-found/route-not-found.element.ts index fc9a835cc9..522228a482 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/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 @@ -5,7 +5,6 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; /** * A fallback view to be used in Workspace Views, maybe this can be upgraded at a later point. */ -// TODO: Rename and move this file to a more generic place. @customElement('umb-route-not-found') export class UmbRouteNotFoundElement extends UmbLitElement { override render() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.context.ts similarity index 94% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.context.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.context.ts index c98ed9a764..cb48d6ec4c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.context.ts @@ -1,12 +1,12 @@ -import { umbGenerateRoutePathBuilder } from '../../generate-route-path-builder.function.js'; -import type { UmbModalRouteRegistration } from '../../modal-registration/modal-route-registration.interface.js'; +import type { IRouterSlot } from '../router-slot/index.js'; +import type { UmbModalRouteRegistration } from '../modal-registration/modal-route-registration.interface.js'; +import { umbGenerateRoutePathBuilder } from '../generate-route-path-builder.function.js'; import type { UmbRoute } from './route.interface.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; import { UmbStringState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; -import type { IRouterSlot } from '../../router-slot/index.js'; const EmptyDiv = document.createElement('div'); @@ -60,6 +60,7 @@ export class UmbRouteContext extends UmbContextBase { #generateRoute(modalRegistration: UmbModalRouteRegistration): UmbRoutePlusModalKey { return { __modalKey: modalRegistration.key, + unique: 'umbModalKey_' + modalRegistration.key, path: '/' + modalRegistration.generateModalPath(), component: EmptyDiv, setup: async (component, info) => { @@ -112,6 +113,7 @@ export class UmbRouteContext extends UmbContextBase { // Add an empty route, so there is a route for the router to react on when no modals are open. this.#modalRoutes.push({ __modalKey: '_empty_', + unique: 'umbEmptyModal', path: '', component: EmptyDiv, }); 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 new file mode 100644 index 0000000000..b46571cdee --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.interface.ts @@ -0,0 +1 @@ +export type { IRoute as UmbRoute } from '../router-slot/model.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot-change.event.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot-change.event.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot-change.event.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot-change.event.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot-init.event.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot-init.event.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot-init.event.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot-init.event.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot.element.ts similarity index 96% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot.element.ts index 77f9d94d84..b440cc2caf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot.element.ts @@ -1,11 +1,11 @@ -import { UmbRoutePathAddendumResetContext } from '../../contexts/route-path-addendum-reset.context.js'; +import type { IRouterSlot } from '../router-slot/index.js'; +import { UmbRoutePathAddendumResetContext } from '../contexts/route-path-addendum-reset.context.js'; import { UmbRouterSlotInitEvent } from './router-slot-init.event.js'; import { UmbRouterSlotChangeEvent } from './router-slot-change.event.js'; import type { UmbRoute } from './route.interface.js'; import { UmbRouteContext } from './route.context.js'; import { css, html, type PropertyValueMap, customElement, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import type { IRouterSlot } from '../../router-slot/index.js'; /** * @element umb-router-slot diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/model.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/model.ts index 45e942b52e..72e669027c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/model.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/model.ts @@ -63,6 +63,9 @@ export interface IRouteBase { // - If "full" router-slot will try to match the entire path. // - If "fuzzy" router-slot will try to match an arbitrary part of the path. pathMatch?: PathMatch; + + // A unique identifier for the route, used to identify the route so we can avoid re-rendering it. + unique?: string | symbol; } /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/router-slot.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/router-slot.ts index 571a63b15d..aca87b52a4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/router-slot.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/router-slot.ts @@ -209,10 +209,8 @@ export class RouterSlot extends HTMLElement implements IRouter // If navigate is not determined, then we will check if we have a route match, and if the new match is different from current. [NL] const newMatch = this.getRouteMatch(); if (newMatch) { - if (this._routeMatch?.route.path !== newMatch.route.path) { - // Check if this match matches the current match (aka. If the path has changed), if so we should navigate. [NL] - navigate = shouldNavigate(this.match, newMatch); - } + // Check if this match matches the current match (aka. If the path has changed), if so we should navigate. [NL] + navigate = shouldNavigate(this.match, newMatch); } } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/util/router.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/util/router.ts index a29de89ed4..37a9644c3a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/util/router.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/util/router.ts @@ -303,9 +303,10 @@ export function shouldNavigate(currentMatch: IRouteMatch | null, newMatch: const { route: currentRoute, fragments: currentFragments } = currentMatch; const { route: newRoute, fragments: newFragments } = newMatch; - const isSameRoute = currentRoute == newRoute; + const isSameRoute = currentRoute.path == newRoute.path; const isSameFragments = currentFragments.consumed == newFragments.consumed; + const isSameBasedOnUnique = currentRoute.unique === newRoute.unique; // Only navigate if the URL consumption is new or if the two routes are no longer the same. - return !isSameFragments || !isSameRoute; + return !isSameFragments || !isSameRoute || !isSameBasedOnUnique; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts index b9e152688a..ad57584422 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts @@ -3,7 +3,7 @@ import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import { UmbId } from '@umbraco-cms/backoffice/id'; import { UmbArrayState, createObservablePart } from '@umbraco-cms/backoffice/observable-api'; -export type UmbValidationMessageType = 'client' | 'server'; +export type UmbValidationMessageType = 'client' | 'server' | 'config' | string; export interface UmbValidationMessage { type: UmbValidationMessageType; key: string; @@ -95,6 +95,14 @@ export class UmbValidationMessagesManager { ); } + messagesOfNotTypeAndPath(type: UmbValidationMessageType, path: string): Observable> { + //path = path.toLowerCase(); + // Find messages that matches the given type and path. + return createObservablePart(this.filteredMessages, (msgs) => + msgs.filter((x) => x.type !== type && x.path === path), + ); + } + hasMessagesOfPathAndDescendant(path: string): Observable { //path = path.toLowerCase(); return createObservablePart(this.filteredMessages, (msgs) => diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts index 6dbb7c465c..7e91dfdd14 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts @@ -43,7 +43,7 @@ export class UmbBindServerValidationToFormControl extends UmbControllerBase { this.#context = context; this.observe( - context.messages?.messagesOfTypeAndPath('server', dataPath), + context.messages?.messagesOfNotTypeAndPath('client', dataPath), (messages) => { this.#messages = messages ?? []; this.#isValid = this.#messages.length === 0; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts index 845b2248af..ae686a5243 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts @@ -70,11 +70,11 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { (component as any).manifest = manifest; } }, - } as UmbRoute; + }; }); // Duplicate first workspace and use it for the empty path scenario. [NL] - newRoutes.push({ ...newRoutes[0], path: '' }); + newRoutes.push({ ...newRoutes[0], unique: newRoutes[0].path, path: '' }); newRoutes.push({ path: `**`, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts index bd835d1734..016eb39d21 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts @@ -358,7 +358,7 @@ export class UmbWorkspaceSplitViewVariantSelectorElement< border: 1px solid var(--uui-color-border); border-radius: var(--uui-border-radius); width: 100%; - height: 100%; + height: auto; box-sizing: border-box; box-shadow: var(--uui-shadow-depth-3); } 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 a2bde07eca..5775788ca4 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 @@ -14,7 +14,7 @@ import { import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api'; import { umbExtensionsRegistry, type ManifestRepository } from '@umbraco-cms/backoffice/extension-registry'; import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository'; -import { UmbStateManager } from '@umbraco-cms/backoffice/utils'; +import { UmbDeprecation, UmbStateManager } from '@umbraco-cms/backoffice/utils'; import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; import { UmbId } from '@umbraco-cms/backoffice/id'; @@ -361,14 +361,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase< return true; } - /* TODO: temp removal of discard changes in workspace modals. - The modal closes before the discard changes dialog is resolved.*/ - // TODO: I think this can go away now??? - if (newUrl.includes('/modal/umb-modal-workspace/')) { - return true; - } - - if (this._checkWillNavigateAway(newUrl) && this._getHasUnpersistedChanges()) { + if (this._checkWillNavigateAway(newUrl) && this.getHasUnpersistedChanges()) { /* Since ours modals are async while events are synchronous, we need to prevent the default behavior of the event, even if the modal hasn’t been resolved yet. Once the modal is resolved (the user accepted to discard the changes and navigate away from the route), we will push a new history state. This push will make the "willchangestate" event happen again and due to this somewhat "backward" behavior, @@ -393,9 +386,18 @@ export abstract class UmbEntityDetailWorkspaceContextBase< * Check if there are unpersisted changes. * @returns { boolean } true if there are unpersisted changes. */ - protected _getHasUnpersistedChanges(): boolean { + public getHasUnpersistedChanges(): boolean { return this._data.getHasUnpersistedChanges(); } + // @deprecated use getHasUnpersistedChanges instead, will be removed in v17.0 + protected _getHasUnpersistedChanges(): boolean { + new UmbDeprecation({ + removeInVersion: '17', + deprecated: '_getHasUnpersistedChanges', + solution: 'use public getHasUnpersistedChanges instead.', + }).warn(); + return this.getHasUnpersistedChanges(); + } override resetState() { super.resetState(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/workspace-info-app.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/workspace-info-app.extension.ts index 19616992aa..0bb567442a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/workspace-info-app.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/workspace-info-app.extension.ts @@ -4,11 +4,11 @@ export interface UmbWorkspaceInfoAppElement extends HTMLElement { manifest?: ManifestWorkspaceInfoApp; } -export interface ManifestWorkspaceInfoApp +export interface ManifestWorkspaceInfoApp extends ManifestElement, ManifestWithDynamicConditions { type: 'workspaceInfoApp'; - meta: MetaWorkspaceInfoApp; + meta: MetaType; } // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/manifests.ts index baae63491c..c61d4c93a7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/manifests.ts @@ -2,6 +2,7 @@ import { UMB_DATA_TYPE_ENTITY_TYPE, UMB_DATA_TYPE_ITEM_REPOSITORY_ALIAS, UMB_DATA_TYPE_DETAIL_REPOSITORY_ALIAS, + UMB_DATA_TYPE_REFERENCE_REPOSITORY_ALIAS, } from '../constants.js'; import { manifests as createManifests } from './create/manifests.js'; import { manifests as moveManifests } from './move-to/manifests.js'; @@ -11,13 +12,14 @@ import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension export const manifests: Array = [ { type: 'entityAction', - kind: 'delete', + kind: 'deleteWithRelation', alias: 'Umb.EntityAction.DataType.Delete', name: 'Delete Data Type Entity Action', forEntityTypes: [UMB_DATA_TYPE_ENTITY_TYPE], meta: { detailRepositoryAlias: UMB_DATA_TYPE_DETAIL_REPOSITORY_ALIAS, itemRepositoryAlias: UMB_DATA_TYPE_ITEM_REPOSITORY_ALIAS, + referenceRepositoryAlias: UMB_DATA_TYPE_REFERENCE_REPOSITORY_ALIAS, }, }, ...createManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/manifests.ts index 1f24894c99..e2085b25ad 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/manifests.ts @@ -3,6 +3,7 @@ import { manifests as dataTypeRootManifest } from './data-type-root/manifests.js import { manifests as entityActions } from './entity-actions/manifests.js'; import { manifests as menuManifests } from './menu/manifests.js'; import { manifests as modalManifests } from './modals/manifests.js'; +import { manifests as referenceManifests } from './reference/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; import { manifests as searchProviderManifests } from './search/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; @@ -15,6 +16,7 @@ export const manifests: Array = ...entityActions, ...menuManifests, ...modalManifests, + ...referenceManifests, ...repositoryManifests, ...searchProviderManifests, ...treeManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/info-app/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/info-app/manifests.ts new file mode 100644 index 0000000000..aceb9124a2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/info-app/manifests.ts @@ -0,0 +1,21 @@ +import { UMB_DATA_TYPE_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_DATA_TYPE_REFERENCE_REPOSITORY_ALIAS } from '../constants.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'workspaceInfoApp', + kind: 'entityReferences', + name: 'Data Type References Workspace Info App', + alias: 'Umb.WorkspaceInfoApp.DataType.References', + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_DATA_TYPE_WORKSPACE_ALIAS, + }, + ], + meta: { + referenceRepositoryAlias: UMB_DATA_TYPE_REFERENCE_REPOSITORY_ALIAS, + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/manifests.ts index 4ac6fbdcb2..cad6350ec8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/manifests.ts @@ -1,3 +1,4 @@ import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as infoAppManifests } from './info-app/manifests.js'; -export const manifests: Array = [...repositoryManifests]; +export const manifests: Array = [...repositoryManifests, ...infoAppManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.repository.ts index 89d330d6c0..336b914ca4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.repository.ts @@ -1,17 +1,9 @@ import { UmbDataTypeReferenceServerDataSource } from './data-type-reference.server.data.js'; -import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; -import type { DataTypeReferenceResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbEntityReferenceRepository } from '@umbraco-cms/backoffice/relations'; -export type UmbDataTypeReferenceModel = { - unique: string; - entityType: string | null; - name: string | null; - icon: string | null; - properties: Array<{ name: string; alias: string }>; -}; - -export class UmbDataTypeReferenceRepository extends UmbControllerBase { +export class UmbDataTypeReferenceRepository extends UmbControllerBase implements UmbEntityReferenceRepository { #referenceSource: UmbDataTypeReferenceServerDataSource; constructor(host: UmbControllerHost) { @@ -19,24 +11,15 @@ export class UmbDataTypeReferenceRepository extends UmbControllerBase { this.#referenceSource = new UmbDataTypeReferenceServerDataSource(this); } - async requestReferencedBy(unique: string) { + async requestReferencedBy(unique: string, skip = 0, take = 20) { if (!unique) throw new Error(`unique is required`); + return this.#referenceSource.getReferencedBy(unique, skip, take); + } - const { data } = await this.#referenceSource.getReferencedBy(unique); - if (!data) return; - - return data.map(mapper); + async requestAreReferenced(uniques: Array, skip = 0, take = 20) { + if (!uniques || uniques.length === 0) throw new Error(`uniques is required`); + return this.#referenceSource.getAreReferenced(uniques, skip, take); } } -const mapper = (item: DataTypeReferenceResponseModel): UmbDataTypeReferenceModel => { - return { - unique: item.contentType.id, - entityType: item.contentType.type, - name: item.contentType.name, - icon: item.contentType.icon, - properties: item.properties, - }; -}; - export default UmbDataTypeReferenceRepository; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.server.data.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.server.data.ts index e6539a7294..7fa8b49d24 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.server.data.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.server.data.ts @@ -1,30 +1,75 @@ import { tryExecute } from '@umbraco-cms/backoffice/resources'; import { DataTypeService } from '@umbraco-cms/backoffice/external/backend-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import type { UmbEntityReferenceDataSource, UmbReferenceItemModel } from '@umbraco-cms/backoffice/relations'; +import type { UmbPagedModel, UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; +import { UmbManagementApiDataMapper } from '@umbraco-cms/backoffice/repository'; /** * @class UmbDataTypeReferenceServerDataSource - * @implements {RepositoryDetailDataSource} + * @implements {UmbEntityReferenceDataSource} */ -export class UmbDataTypeReferenceServerDataSource { - #host: UmbControllerHost; - - /** - * Creates an instance of UmbDataTypeReferenceServerDataSource. - * @param {UmbControllerHost} host - The controller host for this controller to be appended to - * @memberof UmbDataTypeReferenceServerDataSource - */ - constructor(host: UmbControllerHost) { - this.#host = host; - } +export class UmbDataTypeReferenceServerDataSource extends UmbControllerBase implements UmbEntityReferenceDataSource { + #dataMapper = new UmbManagementApiDataMapper(this); /** * Fetches the item for the given unique from the server - * @param {string} id - * @returns {*} + * @param {string} unique - The unique identifier of the item to fetch + * @param {number} skip - The number of items to skip + * @param {number} take - The number of items to take + * @returns {Promise>>} - Items that are referenced by the given unique * @memberof UmbDataTypeReferenceServerDataSource */ - async getReferencedBy(id: string) { - return await tryExecute(this.#host, DataTypeService.getDataTypeByIdReferences({ id })); + async getReferencedBy( + unique: string, + skip = 0, + take = 20, + ): Promise>> { + const { data, error } = await tryExecute( + this, + DataTypeService.getDataTypeByIdReferencedBy({ id: unique, skip, take }), + ); + + if (data) { + const promises = data.items.map(async (item) => { + return this.#dataMapper.map({ + forDataModel: item.$type, + data: item, + fallback: async () => { + return { + ...item, + unique: item.id, + entityType: 'unknown', + }; + }, + }); + }); + + const items = await Promise.all(promises); + + return { data: { items, total: data.total } }; + } + + return { data, error }; + } + + /** + * Checks if the items are referenced by other items + * @param {Array} uniques - The unique identifiers of the items to fetch + * @param {number} skip - The number of items to skip + * @param {number} take - The number of items to take + * @returns {Promise>>} - Items that are referenced by other items + * @memberof UmbDataTypeReferenceServerDataSource + */ + async getAreReferenced( + uniques: Array, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + skip: number = 0, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + take: number = 20, + ): Promise>> { + console.warn('getAreReferenced is not implemented for DataTypeReferenceServerDataSource'); + return { data: { items: [], total: 0 } }; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/data-type-workspace-view-info-reference.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/data-type-workspace-view-info-reference.element.ts deleted file mode 100644 index a1f0d2476f..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/data-type-workspace-view-info-reference.element.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { UmbDataTypeReferenceRepository } from '../../../reference/index.js'; -import type { UmbDataTypeReferenceModel } from '../../../reference/index.js'; -import { css, html, customElement, state, repeat, property, when } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; -import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; -import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; - -const elementName = 'umb-data-type-workspace-view-info-reference'; - -@customElement(elementName) -export class UmbDataTypeWorkspaceViewInfoReferenceElement extends UmbLitElement { - #referenceRepository = new UmbDataTypeReferenceRepository(this); - - #routeBuilder?: UmbModalRouteBuilder; - - @property() - dataTypeUnique = ''; - - @state() - private _loading = true; - - @state() - private _items?: Array = []; - - constructor() { - super(); - - new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) - .addAdditionalPath(':entityType') - .onSetup((params) => { - return { data: { entityType: params.entityType, preset: {} } }; - }) - .observeRouteBuilder((routeBuilder) => { - this.#routeBuilder = routeBuilder; - }); - } - - protected override firstUpdated() { - this.#getReferences(); - } - - async #getReferences() { - this._loading = true; - - const items = await this.#referenceRepository.requestReferencedBy(this.dataTypeUnique); - if (!items) return; - - this._items = items; - this._loading = false; - } - - override render() { - return html` - - ${when( - this._loading, - () => html``, - () => this.#renderItems(), - )} - - `; - } - - #getEditPath(item: UmbDataTypeReferenceModel) { - // TODO: [LK] Ask NL for a reminder on how the route constants work. - return this.#routeBuilder && item.entityType - ? this.#routeBuilder({ entityType: item.entityType }) + `edit/${item.unique}` - : '#'; - } - - #renderItems() { - if (!this._items?.length) return html`

${this.localize.term('references_DataTypeNoReferences')}

`; - return html` - - - Name - Type - - Referenced by - - - ${repeat( - this._items, - (item) => item.unique, - (item) => html` - - - - - - - ${item.entityType} - ${item.properties.map((prop) => prop.name).join(', ')} - - `, - )} - - `; - } - - static override styles = [ - UmbTextStyles, - css` - :host { - display: contents; - } - uui-table-cell { - color: var(--uui-color-text-alt); - } - `, - ]; -} - -export { UmbDataTypeWorkspaceViewInfoReferenceElement as element }; - -declare global { - interface HTMLElementTagNameMap { - [elementName]: UmbDataTypeWorkspaceViewInfoReferenceElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/workspace-view-data-type-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/workspace-view-data-type-info.element.ts index 81e6eb9046..e6814696a8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/workspace-view-data-type-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/workspace-view-data-type-info.element.ts @@ -4,8 +4,6 @@ import { css, html, customElement, state } from '@umbraco-cms/backoffice/externa import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'; -import './data-type-workspace-view-info-reference.element.js'; - @customElement('umb-workspace-view-data-type-info') export class UmbWorkspaceViewDataTypeInfoElement extends UmbLitElement implements UmbWorkspaceViewElement { @state() @@ -47,8 +45,7 @@ export class UmbWorkspaceViewDataTypeInfoElement extends UmbLitElement implement override render() { return html`
- +
${this.#renderGeneralInfo()}
`; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/constants.ts index 1b4981f65a..d07e60d102 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/constants.ts @@ -1,7 +1,8 @@ -export * from './paths.js'; export * from './entity-actions/constants.js'; -export * from './search/constants.js'; +export * from './paths.js'; +export * from './property-type/constants.js'; export * from './repository/constants.js'; +export * from './search/constants.js'; export * from './tree/constants.js'; export * from './workspace/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/manifests.ts index 3562f9d6a7..1fea74a755 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/manifests.ts @@ -1,6 +1,7 @@ import { manifests as entityActionsManifests } from './entity-actions/manifests.js'; import { manifests as menuManifests } from './menu/manifests.js'; import { manifests as propertyEditorManifests } from './property-editors/manifests.js'; +import { manifests as propertyTypeManifests } from './property-type/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; import { manifests as searchManifests } from './search/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; @@ -11,6 +12,7 @@ export const manifests: Array = ...entityActionsManifests, ...menuManifests, ...propertyEditorManifests, + ...propertyTypeManifests, ...repositoryManifests, ...searchManifests, ...treeManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/constants.ts new file mode 100644 index 0000000000..e86be43738 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/constants.ts @@ -0,0 +1 @@ +export { UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-item-ref.element.ts new file mode 100644 index 0000000000..a841c162c9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-item-ref.element.ts @@ -0,0 +1,80 @@ +import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '../entity.js'; +import { UMB_EDIT_DOCUMENT_TYPE_WORKSPACE_PATH_PATTERN } from '../paths.js'; +import type { UmbDocumentTypePropertyTypeReferenceModel } from './types.js'; +import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; + +@customElement('umb-document-type-property-type-item-ref') +export class UmbDocumentTypePropertyTypeItemRefElement extends UmbLitElement { + @property({ type: Object }) + item?: UmbDocumentTypePropertyTypeReferenceModel; + + @property({ type: Boolean }) + readonly = false; + + @property({ type: Boolean }) + standalone = false; + + @state() + _editPath = ''; + + constructor() { + super(); + + new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) + .addUniquePaths(['unique']) + .onSetup(() => { + return { data: { entityType: UMB_DOCUMENT_TYPE_ENTITY_TYPE, preset: {} } }; + }) + .observeRouteBuilder((routeBuilder) => { + this._editPath = routeBuilder({}); + }); + } + + #getHref() { + if (!this.item?.unique) return; + const path = UMB_EDIT_DOCUMENT_TYPE_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this.item.documentType.unique }); + return `${this._editPath}/${path}`; + } + + #getName() { + const documentTypeName = this.item?.documentType.name ?? 'Unknown'; + return `Document Type: ${documentTypeName}`; + } + + #getDetail() { + const propertyTypeDetails = this.item?.name ? this.item.name + ' (' + this.item.alias + ')' : 'Unknown'; + return `Property Type: ${propertyTypeDetails}`; + } + + override render() { + if (!this.item) return nothing; + + return html` + + + ${this.#renderIcon()} + + `; + } + + #renderIcon() { + if (!this.item?.documentType.icon) return nothing; + return html``; + } +} + +export { UmbDocumentTypePropertyTypeItemRefElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-document-type-property-type-item-ref': UmbDocumentTypePropertyTypeItemRefElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-reference-response.management-api.mapping.ts new file mode 100644 index 0000000000..87cd65c479 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-reference-response.management-api.mapping.ts @@ -0,0 +1,28 @@ +import type { UmbDocumentTypePropertyTypeReferenceModel } from './types.js'; +import { UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import type { DocumentTypePropertyTypeReferenceResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbDataSourceDataMapping } from '@umbraco-cms/backoffice/repository'; + +export class UmbDocumentTypePropertyTypeReferenceResponseManagementApiDataMapping + extends UmbControllerBase + implements + UmbDataSourceDataMapping +{ + async map(data: DocumentTypePropertyTypeReferenceResponseModel): Promise { + return { + alias: data.alias!, + documentType: { + alias: data.documentType.alias!, + icon: data.documentType.icon!, + name: data.documentType.name!, + unique: data.documentType.id, + }, + entityType: UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE, + name: data.name!, + unique: data.id, + }; + } +} + +export { UmbDocumentTypePropertyTypeReferenceResponseManagementApiDataMapping as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/entity.ts new file mode 100644 index 0000000000..be229d94cd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/entity.ts @@ -0,0 +1,3 @@ +export const UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE = 'document-type-property-type'; + +export type UmbDocumentTypePropertyTypeEntityType = typeof UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/manifests.ts new file mode 100644 index 0000000000..e09a5cb902 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/manifests.ts @@ -0,0 +1,20 @@ +import { UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import { UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS } from '@umbraco-cms/backoffice/repository'; + +export const manifests: Array = [ + { + type: 'dataSourceDataMapping', + alias: 'Umb.DataSourceDataMapping.ManagementApi.DocumentTypePropertyTypeReferenceResponse', + name: 'Document Type Property Type Reference Response Management Api Data Mapping', + api: () => import('./document-type-property-type-reference-response.management-api.mapping.js'), + forDataSource: UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS, + forDataModel: 'DocumentTypePropertyTypeReferenceResponseModel', + }, + { + type: 'entityItemRef', + alias: 'Umb.EntityItemRef.DocumentTypePropertyType', + name: 'Document Type Property Type Entity Item Reference', + element: () => import('./document-type-property-type-item-ref.element.js'), + forEntityTypes: [UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/types.ts new file mode 100644 index 0000000000..8668fbd8ba --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/types.ts @@ -0,0 +1,12 @@ +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbDocumentTypePropertyTypeReferenceModel extends UmbEntityModel { + alias: string; + documentType: { + alias: string; + icon: string; + name: string; + unique: string; + }; + name: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/views/settings/document-type-workspace-view-settings.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/views/settings/document-type-workspace-view-settings.element.ts index 6dac23b19e..51f689e68c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/views/settings/document-type-workspace-view-settings.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/views/settings/document-type-workspace-view-settings.element.ts @@ -1,5 +1,5 @@ import { UMB_DOCUMENT_TYPE_WORKSPACE_CONTEXT } from '../../document-type-workspace.context-token.js'; -import { css, html, customElement, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, state, when, nothing } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UUIBooleanInputEvent, UUIToggleElement } from '@umbraco-cms/backoffice/external/uui'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @@ -103,23 +103,29 @@ export class UmbDocumentTypeWorkspaceViewSettingsElement extends UmbLitElement i label=${this.localize.term('contentTypeEditor_cultureVariantLabel')}> - -
- Allow editors to segment their content. -
-
- { - this.#workspaceContext?.setVariesBySegment((e.target as UUIToggleElement).checked); - }} - label=${this.localize.term('contentTypeEditor_segmentVariantLabel')}> -
-
+ + ${this._isElement + ? nothing + : html` + +
+ Allow editors to segment their content. +
+
+ { + this.#workspaceContext?.setVariesBySegment((e.target as UUIToggleElement).checked); + }} + label=${this.localize.term('contentTypeEditor_segmentVariantLabel')}> +
+
+ `} +
setTimeout(resolve, isFirstPoll ? 1000 : 5000)); + isFirstPoll = false; + const { data, error } = await tryExecute( + this.#host, + DocumentService.getDocumentByIdPublishWithDescendantsResultByTaskId({ id: unique, taskId })); + if (error || !data) { + return { error }; + } + + if (data.isComplete) { + return { error: null }; + } + + } } /** @@ -118,7 +146,10 @@ export class UmbDocumentPublishingServerDataSource { async published(unique: string): Promise> { if (!unique) throw new Error('Unique is missing'); - const { data, error } = await tryExecute(this.#host, DocumentService.getDocumentByIdPublished({ id: unique })); + const { data, error } = await tryExecute( + this.#host, + DocumentService.getDocumentByIdPublished({ id: unique }), + ); if (error || !data) { return { error }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/manifests.ts index 42f6e34b19..d35bb15071 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/manifests.ts @@ -1,18 +1,21 @@ import { UMB_DOCUMENT_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_DOCUMENT_REFERENCE_REPOSITORY_ALIAS } from '../constants.js'; import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; export const manifests: Array = [ { type: 'workspaceInfoApp', + kind: 'entityReferences', name: 'Document References Workspace Info App', alias: 'Umb.WorkspaceInfoApp.Document.References', - element: () => import('./document-references-workspace-view-info.element.js'), - weight: 90, conditions: [ { alias: UMB_WORKSPACE_CONDITION_ALIAS, match: UMB_DOCUMENT_WORKSPACE_ALIAS, }, ], + meta: { + referenceRepositoryAlias: UMB_DOCUMENT_REFERENCE_REPOSITORY_ALIAS, + }, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/repository/document-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/repository/document-reference-response.management-api.mapping.ts index 5dc5b08754..ecc3b4fd23 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/repository/document-reference-response.management-api.mapping.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/repository/document-reference-response.management-api.mapping.ts @@ -14,9 +14,10 @@ export class UmbDocumentReferenceResponseManagementApiDataMapping async map(data: DocumentReferenceResponseModel): Promise { return { documentType: { - alias: data.documentType.alias, - icon: data.documentType.icon, - name: data.documentType.name, + alias: data.documentType.alias!, + icon: data.documentType.icon!, + name: data.documentType.name!, + unique: data.documentType.id, }, entityType: UMB_DOCUMENT_ENTITY_TYPE, id: data.id, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/types.ts index 90496f5d87..efd670dd1f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/types.ts @@ -1,6 +1,5 @@ import type { UmbDocumentItemVariantModel } from '../item/types.js'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; -import type { TrackedReferenceDocumentTypeModel } from '@umbraco-cms/backoffice/external/backend-api'; export interface UmbDocumentReferenceModel extends UmbEntityModel { /** @@ -23,6 +22,11 @@ export interface UmbDocumentReferenceModel extends UmbEntityModel { * @memberof UmbDocumentReferenceModel */ published?: boolean | null; - documentType: TrackedReferenceDocumentTypeModel; + documentType: { + alias: string; + icon: string; + name: string; + unique: string; + }; variants: Array; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/constants.ts index 53e68e6f83..5bc61b44e3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/constants.ts @@ -1,6 +1,7 @@ export * from './entity-actions/constants.js'; export * from './media-type-root/constants.js'; export * from './paths.js'; +export * from './property-type/constants.js'; export * from './repository/constants.js'; export * from './tree/constants.js'; export * from './workspace/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/manifests.ts index 49a80defb6..a1a7a478c8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/manifests.ts @@ -1,18 +1,20 @@ import { manifests as entityActionsManifests } from './entity-actions/manifests.js'; import { manifests as menuManifests } from './menu/manifests.js'; +import { manifests as propertyEditorUiManifests } from './property-editors/manifests.js'; +import { manifests as propertyTypeManifests } from './property-type/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as searchManifests } from './search/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; import { manifests as workspaceManifests } from './workspace/manifests.js'; -import { manifests as propertyEditorUiManifests } from './property-editors/manifests.js'; -import { manifests as searchManifests } from './search/manifests.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; export const manifests: Array = [ ...entityActionsManifests, ...menuManifests, + ...propertyEditorUiManifests, + ...propertyTypeManifests, ...repositoryManifests, + ...searchManifests, ...treeManifests, ...workspaceManifests, - ...propertyEditorUiManifests, - ...searchManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/constants.ts new file mode 100644 index 0000000000..e6a1ec27f1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/constants.ts @@ -0,0 +1 @@ +export { UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/entity.ts new file mode 100644 index 0000000000..7614ba1a74 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/entity.ts @@ -0,0 +1,3 @@ +export const UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE = 'media-type-property-type'; + +export type UmbMediaTypePropertyTypeEntityType = typeof UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/manifests.ts new file mode 100644 index 0000000000..4a83872ffe --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/manifests.ts @@ -0,0 +1,20 @@ +import { UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import { UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS } from '@umbraco-cms/backoffice/repository'; + +export const manifests: Array = [ + { + type: 'dataSourceDataMapping', + alias: 'Umb.DataSourceDataMapping.ManagementApi.MediaTypePropertyTypeReferenceResponse', + name: 'Media Type Property Type Reference Response Management Api Data Mapping', + api: () => import('./media-type-property-type-reference-response.management-api.mapping.js'), + forDataSource: UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS, + forDataModel: 'MediaTypePropertyTypeReferenceResponseModel', + }, + { + type: 'entityItemRef', + alias: 'Umb.EntityItemRef.MediaTypePropertyType', + name: 'Media Type Property Type Entity Item Reference', + element: () => import('./media-type-property-type-item-ref.element.js'), + forEntityTypes: [UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-item-ref.element.ts new file mode 100644 index 0000000000..76bb1176a5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-item-ref.element.ts @@ -0,0 +1,80 @@ +import { UMB_MEDIA_TYPE_ENTITY_TYPE } from '../entity.js'; +import { UMB_EDIT_MEDIA_TYPE_WORKSPACE_PATH_PATTERN } from '../paths.js'; +import type { UmbMediaTypePropertyTypeReferenceModel } from './types.js'; +import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; + +@customElement('umb-media-type-property-type-item-ref') +export class UmbMediaTypePropertyTypeItemRefElement extends UmbLitElement { + @property({ type: Object }) + item?: UmbMediaTypePropertyTypeReferenceModel; + + @property({ type: Boolean }) + readonly = false; + + @property({ type: Boolean }) + standalone = false; + + @state() + _editPath = ''; + + constructor() { + super(); + + new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) + .addUniquePaths(['unique']) + .onSetup(() => { + return { data: { entityType: UMB_MEDIA_TYPE_ENTITY_TYPE, preset: {} } }; + }) + .observeRouteBuilder((routeBuilder) => { + this._editPath = routeBuilder({}); + }); + } + + #getHref() { + if (!this.item?.unique) return; + const path = UMB_EDIT_MEDIA_TYPE_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this.item.mediaType.unique }); + return `${this._editPath}/${path}`; + } + + #getName() { + const mediaTypeName = this.item?.mediaType.name ?? 'Unknown'; + return `Media Type: ${mediaTypeName}`; + } + + #getDetail() { + const propertyTypeDetails = this.item?.name ? this.item.name + ' (' + this.item.alias + ')' : 'Unknown'; + return `Property Type: ${propertyTypeDetails}`; + } + + override render() { + if (!this.item) return nothing; + + return html` + + + ${this.#renderIcon()} + + `; + } + + #renderIcon() { + if (!this.item?.mediaType.icon) return nothing; + return html``; + } +} + +export { UmbMediaTypePropertyTypeItemRefElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-media-type-property-type-item-ref': UmbMediaTypePropertyTypeItemRefElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-reference-response.management-api.mapping.ts new file mode 100644 index 0000000000..641fe13ba7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-reference-response.management-api.mapping.ts @@ -0,0 +1,28 @@ +import type { UmbMediaTypePropertyTypeReferenceModel } from './types.js'; +import { UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import type { MediaTypePropertyTypeReferenceResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbDataSourceDataMapping } from '@umbraco-cms/backoffice/repository'; + +export class UmbMediaTypePropertyTypeReferenceResponseManagementApiDataMapping + extends UmbControllerBase + implements + UmbDataSourceDataMapping +{ + async map(data: MediaTypePropertyTypeReferenceResponseModel): Promise { + return { + alias: data.alias!, + mediaType: { + alias: data.mediaType.alias!, + icon: data.mediaType.icon!, + name: data.mediaType.name!, + unique: data.mediaType.id, + }, + entityType: UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE, + name: data.name!, + unique: data.id, + }; + } +} + +export { UmbMediaTypePropertyTypeReferenceResponseManagementApiDataMapping as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/types.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/types.ts new file mode 100644 index 0000000000..b314e7cf6f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/types.ts @@ -0,0 +1,12 @@ +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbMediaTypePropertyTypeReferenceModel extends UmbEntityModel { + alias: string; + mediaType: { + alias: string; + icon: string; + name: string; + unique: string; + }; + name: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/manifests.ts index 6740bdc27f..bc56ed6951 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/manifests.ts @@ -1,18 +1,21 @@ import { UMB_MEDIA_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_MEDIA_REFERENCE_REPOSITORY_ALIAS } from '../constants.js'; import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; export const manifests: Array = [ { type: 'workspaceInfoApp', + kind: 'entityReferences', name: 'Media References Workspace Info App', alias: 'Umb.WorkspaceInfoApp.Media.References', - element: () => import('./media-references-workspace-info-app.element.js'), - weight: 90, conditions: [ { alias: UMB_WORKSPACE_CONDITION_ALIAS, match: UMB_MEDIA_WORKSPACE_ALIAS, }, ], + meta: { + referenceRepositoryAlias: UMB_MEDIA_REFERENCE_REPOSITORY_ALIAS, + }, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/media-references-workspace-info-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/media-references-workspace-info-app.element.ts deleted file mode 100644 index 5a6151a8c4..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/media-references-workspace-info-app.element.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { UmbMediaReferenceRepository } from '../repository/index.js'; -import { UMB_MEDIA_WORKSPACE_CONTEXT } from '../../workspace/constants.js'; -import { css, customElement, html, nothing, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { UmbReferenceItemModel } from '@umbraco-cms/backoffice/relations'; -import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; -import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; - -@customElement('umb-media-references-workspace-info-app') -export class UmbMediaReferencesWorkspaceInfoAppElement extends UmbLitElement { - #itemsPerPage = 10; - - #referenceRepository; - - @state() - private _currentPage = 1; - - @state() - private _total = 0; - - @state() - private _items?: Array = []; - - @state() - private _loading = true; - - #workspaceContext?: typeof UMB_MEDIA_WORKSPACE_CONTEXT.TYPE; - #mediaUnique?: UmbEntityUnique; - - constructor() { - super(); - this.#referenceRepository = new UmbMediaReferenceRepository(this); - - this.consumeContext(UMB_MEDIA_WORKSPACE_CONTEXT, (context) => { - this.#workspaceContext = context; - this.#observeMediaUnique(); - }); - } - - #observeMediaUnique() { - this.observe( - this.#workspaceContext?.unique, - (unique) => { - if (!unique) { - this.#mediaUnique = undefined; - this._items = []; - return; - } - - if (this.#mediaUnique === unique) { - return; - } - - this.#mediaUnique = unique; - this.#getReferences(); - }, - 'umbReferencesDocumentUniqueObserver', - ); - } - - async #getReferences() { - if (!this.#mediaUnique) { - throw new Error('Media unique is required'); - } - - this._loading = true; - - const { data } = await this.#referenceRepository.requestReferencedBy( - this.#mediaUnique, - (this._currentPage - 1) * this.#itemsPerPage, - this.#itemsPerPage, - ); - - if (!data) return; - - this._total = data.total; - this._items = data.items; - - this._loading = false; - } - - #onPageChange(event: UUIPaginationEvent) { - if (this._currentPage === event.target.current) return; - this._currentPage = event.target.current; - - this.#getReferences(); - } - - override render() { - if (!this._items?.length) return nothing; - return html` - - ${when( - this._loading, - () => html``, - () => html`${this.#renderItems()} ${this.#renderPagination()}`, - )} - - `; - } - - #renderItems() { - if (!this._items) return; - return html` - - ${repeat( - this._items, - (item) => item.unique, - (item) => html``, - )} - - `; - } - - #renderPagination() { - if (!this._total) return nothing; - - const totalPages = Math.ceil(this._total / this.#itemsPerPage); - - if (totalPages <= 1) return nothing; - - return html` - - `; - } - - static override styles = [ - UmbTextStyles, - css` - :host { - display: contents; - } - - uui-table-cell { - color: var(--uui-color-text-alt); - } - - uui-pagination { - flex: 1; - display: inline-block; - } - - .pagination { - display: flex; - justify-content: center; - margin-top: var(--uui-size-space-4); - } - `, - ]; -} - -export default UmbMediaReferencesWorkspaceInfoAppElement; - -declare global { - interface HTMLElementTagNameMap { - 'umb-media-references-workspace-info-app': UmbMediaReferencesWorkspaceInfoAppElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/media-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/media-reference-response.management-api.mapping.ts index e9c29762e5..34a7a83d7a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/media-reference-response.management-api.mapping.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/media-reference-response.management-api.mapping.ts @@ -13,9 +13,10 @@ export class UmbMediaReferenceResponseManagementApiDataMapping entityType: UMB_MEDIA_ENTITY_TYPE, id: data.id, mediaType: { - alias: data.mediaType.alias, - icon: data.mediaType.icon, - name: data.mediaType.name, + alias: data.mediaType.alias!, + icon: data.mediaType.icon!, + name: data.mediaType.name!, + unique: data.mediaType.id, }, name: data.name, // TODO: this is a hardcoded array until the server can return the correct variants array diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/types.ts index 69220e6301..fa9ef98990 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/types.ts @@ -1,6 +1,5 @@ import type { UmbMediaItemVariantModel } from '../../repository/item/types.js'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; -import type { TrackedReferenceMediaTypeModel } from '@umbraco-cms/backoffice/external/backend-api'; export interface UmbMediaReferenceModel extends UmbEntityModel { /** @@ -23,6 +22,11 @@ export interface UmbMediaReferenceModel extends UmbEntityModel { * @memberof UmbMediaReferenceModel */ published?: boolean | null; - mediaType: TrackedReferenceMediaTypeModel; + mediaType: { + alias: string; + icon: string; + name: string; + unique: string; + }; variants: Array; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/constants.ts index 75fe65204b..8e60cb02fa 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/constants.ts @@ -1,7 +1,8 @@ export * from './entity-actions/constants.js'; +export * from './paths.js'; +export * from './property-type/constants.js'; export * from './repository/constants.js'; export * from './tree/constants.js'; export * from './workspace/constants.js'; -export * from './paths.js'; export { UMB_MEMBER_TYPE_ROOT_ENTITY_TYPE, UMB_MEMBER_TYPE_ENTITY_TYPE } from './entity.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/manifests.ts index 96b4dceda1..ba33f43db6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/manifests.ts @@ -1,5 +1,6 @@ import { manifests as entityActionsManifests } from './entity-actions/manifests.js'; import { manifests as menuManifests } from './menu/manifests.js'; +import { manifests as propertyTypeManifests } from './property-type/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; import { manifests as searchManifests } from './search/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; @@ -11,6 +12,7 @@ import './components/index.js'; export const manifests: Array = [ ...entityActionsManifests, ...menuManifests, + ...propertyTypeManifests, ...repositoryManifests, ...searchManifests, ...treeManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/constants.ts new file mode 100644 index 0000000000..ce977e948b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/constants.ts @@ -0,0 +1 @@ +export { UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/entity.ts new file mode 100644 index 0000000000..dd0d0a29e0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/entity.ts @@ -0,0 +1,3 @@ +export const UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE = 'member-type-property-type'; + +export type UmbMemberTypePropertyTypeEntityType = typeof UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/manifests.ts new file mode 100644 index 0000000000..7b50fb5c2d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/manifests.ts @@ -0,0 +1,20 @@ +import { UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import { UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS } from '@umbraco-cms/backoffice/repository'; + +export const manifests: Array = [ + { + type: 'dataSourceDataMapping', + alias: 'Umb.DataSourceDataMapping.ManagementApi.MemberTypePropertyTypeReferenceResponse', + name: 'Member Type Property Type Reference Response Management Api Data Mapping', + api: () => import('./member-type-property-type-reference-response.management-api.mapping.js'), + forDataSource: UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS, + forDataModel: 'MemberTypePropertyTypeReferenceResponseModel', + }, + { + type: 'entityItemRef', + alias: 'Umb.EntityItemRef.MemberTypePropertyType', + name: 'Member Type Property Type Entity Item Reference', + element: () => import('./member-type-property-type-item-ref.element.js'), + forEntityTypes: [UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-item-ref.element.ts new file mode 100644 index 0000000000..e55489b82a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-item-ref.element.ts @@ -0,0 +1,80 @@ +import { UMB_MEMBER_TYPE_ENTITY_TYPE } from '../entity.js'; +import { UMB_EDIT_MEMBER_TYPE_WORKSPACE_PATH_PATTERN } from '../paths.js'; +import type { UmbMemberTypePropertyTypeReferenceModel } from './types.js'; +import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; + +@customElement('umb-member-type-property-type-item-ref') +export class UmbMemberTypePropertyTypeItemRefElement extends UmbLitElement { + @property({ type: Object }) + item?: UmbMemberTypePropertyTypeReferenceModel; + + @property({ type: Boolean }) + readonly = false; + + @property({ type: Boolean }) + standalone = false; + + @state() + _editPath = ''; + + constructor() { + super(); + + new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) + .addUniquePaths(['unique']) + .onSetup(() => { + return { data: { entityType: UMB_MEMBER_TYPE_ENTITY_TYPE, preset: {} } }; + }) + .observeRouteBuilder((routeBuilder) => { + this._editPath = routeBuilder({}); + }); + } + + #getHref() { + if (!this.item?.unique) return; + const path = UMB_EDIT_MEMBER_TYPE_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this.item.memberType.unique }); + return `${this._editPath}/${path}`; + } + + #getName() { + const memberTypeName = this.item?.memberType.name ?? 'Unknown'; + return `Member Type: ${memberTypeName}`; + } + + #getDetail() { + const propertyTypeDetails = this.item?.name ? this.item.name + ' (' + this.item.alias + ')' : 'Unknown'; + return `Property Type: ${propertyTypeDetails}`; + } + + override render() { + if (!this.item) return nothing; + + return html` + + + ${this.#renderIcon()} + + `; + } + + #renderIcon() { + if (!this.item?.memberType.icon) return nothing; + return html``; + } +} + +export { UmbMemberTypePropertyTypeItemRefElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-member-type-property-type-item-ref': UmbMemberTypePropertyTypeItemRefElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-reference-response.management-api.mapping.ts new file mode 100644 index 0000000000..17ee9a86a5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-reference-response.management-api.mapping.ts @@ -0,0 +1,28 @@ +import type { UmbMemberTypePropertyTypeReferenceModel } from './types.js'; +import { UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import type { MemberTypePropertyTypeReferenceResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbDataSourceDataMapping } from '@umbraco-cms/backoffice/repository'; + +export class UmbMemberTypePropertyTypeReferenceResponseManagementApiDataMapping + extends UmbControllerBase + implements + UmbDataSourceDataMapping +{ + async map(data: MemberTypePropertyTypeReferenceResponseModel): Promise { + return { + alias: data.alias!, + memberType: { + alias: data.memberType.alias!, + icon: data.memberType.icon!, + name: data.memberType.name!, + unique: data.memberType.id, + }, + entityType: UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE, + name: data.name!, + unique: data.id, + }; + } +} + +export { UmbMemberTypePropertyTypeReferenceResponseManagementApiDataMapping as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/types.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/types.ts new file mode 100644 index 0000000000..566b91fe24 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/types.ts @@ -0,0 +1,12 @@ +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbMemberTypePropertyTypeReferenceModel extends UmbEntityModel { + alias: string; + memberType: { + alias: string; + icon: string; + name: string; + unique: string; + }; + name: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/manifests.ts index 7ebbb37e12..518dec22ab 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/manifests.ts @@ -1,18 +1,21 @@ import { UMB_MEMBER_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_MEMBER_REFERENCE_REPOSITORY_ALIAS } from '../constants.js'; import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; export const manifests: Array = [ { type: 'workspaceInfoApp', + kind: 'entityReferences', name: 'Member References Workspace Info App', alias: 'Umb.WorkspaceInfoApp.Member.References', - element: () => import('./member-references-workspace-info-app.element.js'), - weight: 90, conditions: [ { alias: UMB_WORKSPACE_CONDITION_ALIAS, match: UMB_MEMBER_WORKSPACE_ALIAS, }, ], + meta: { + referenceRepositoryAlias: UMB_MEMBER_REFERENCE_REPOSITORY_ALIAS, + }, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/member-references-workspace-info-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/member-references-workspace-info-app.element.ts deleted file mode 100644 index 23828e7026..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/member-references-workspace-info-app.element.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { UmbMemberReferenceRepository } from '../repository/index.js'; -import { UMB_MEMBER_WORKSPACE_CONTEXT } from '../../workspace/constants.js'; -import { css, customElement, html, nothing, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { UmbReferenceItemModel } from '@umbraco-cms/backoffice/relations'; -import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; -import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; - -@customElement('umb-member-references-workspace-info-app') -export class UmbMemberReferencesWorkspaceInfoAppElement extends UmbLitElement { - #itemsPerPage = 10; - - #referenceRepository; - - @state() - private _currentPage = 1; - - @state() - private _total = 0; - - @state() - private _items?: Array = []; - - @state() - private _loading = true; - - #workspaceContext?: typeof UMB_MEMBER_WORKSPACE_CONTEXT.TYPE; - #memberUnique?: UmbEntityUnique; - - constructor() { - super(); - this.#referenceRepository = new UmbMemberReferenceRepository(this); - - this.consumeContext(UMB_MEMBER_WORKSPACE_CONTEXT, (context) => { - this.#workspaceContext = context; - this.#observeMemberUnique(); - }); - } - - #observeMemberUnique() { - this.observe( - this.#workspaceContext?.unique, - (unique) => { - if (!unique) { - this.#memberUnique = undefined; - this._items = []; - return; - } - - if (this.#memberUnique === unique) { - return; - } - - this.#memberUnique = unique; - this.#getReferences(); - }, - 'umbReferencesDocumentUniqueObserver', - ); - } - - async #getReferences() { - if (!this.#memberUnique) { - throw new Error('Member unique is required'); - } - - this._loading = true; - - const { data } = await this.#referenceRepository.requestReferencedBy( - this.#memberUnique, - (this._currentPage - 1) * this.#itemsPerPage, - this.#itemsPerPage, - ); - - if (!data) return; - - this._total = data.total; - this._items = data.items; - - this._loading = false; - } - - #onPageChange(event: UUIPaginationEvent) { - if (this._currentPage === event.target.current) return; - this._currentPage = event.target.current; - - this.#getReferences(); - } - - override render() { - if (!this._items?.length) return nothing; - return html` - - ${when( - this._loading, - () => html``, - () => html`${this.#renderItems()} ${this.#renderPagination()}`, - )} - - `; - } - - #renderItems() { - if (!this._items) return; - return html` - - ${repeat( - this._items, - (item) => item.unique, - (item) => html``, - )} - - `; - } - - #renderPagination() { - if (!this._total) return nothing; - - const totalPages = Math.ceil(this._total / this.#itemsPerPage); - - if (totalPages <= 1) return nothing; - - return html` - - `; - } - - static override styles = [ - UmbTextStyles, - css` - :host { - display: contents; - } - - uui-table-cell { - color: var(--uui-color-text-alt); - } - - uui-pagination { - flex: 1; - display: inline-block; - } - - .pagination { - display: flex; - justify-content: center; - margin-top: var(--uui-size-space-4); - } - `, - ]; -} - -export default UmbMemberReferencesWorkspaceInfoAppElement; - -declare global { - interface HTMLElementTagNameMap { - 'umb-member-references-workspace-info-app': UmbMemberReferencesWorkspaceInfoAppElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/member-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/member-reference-response.management-api.mapping.ts index e6418503e8..408774eca0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/member-reference-response.management-api.mapping.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/member-reference-response.management-api.mapping.ts @@ -12,9 +12,10 @@ export class UmbMemberReferenceResponseManagementApiDataMapping return { entityType: UMB_MEMBER_ENTITY_TYPE, memberType: { - alias: data.memberType.alias, - icon: data.memberType.icon, - name: data.memberType.name, + alias: data.memberType.alias!, + icon: data.memberType.icon!, + name: data.memberType.name!, + unique: data.memberType.id, }, name: data.name, // TODO: this is a hardcoded array until the server can return the correct variants array diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/types.ts index bc62433db5..c63aad90ce 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/types.ts @@ -1,6 +1,5 @@ import type { UmbMemberItemVariantModel } from '../../item/types.js'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; -import type { TrackedReferenceMemberTypeModel } from '@umbraco-cms/backoffice/external/backend-api'; export interface UmbMemberReferenceModel extends UmbEntityModel { /** @@ -9,6 +8,11 @@ export interface UmbMemberReferenceModel extends UmbEntityModel { * @memberof UmbMemberReferenceModel */ name?: string | null; - memberType: TrackedReferenceMemberTypeModel; + memberType: { + alias: string; + icon: string; + name: string; + unique: string; + }; variants: Array; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/manifests.ts index fceac92145..1d63998360 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/manifests.ts @@ -3,6 +3,7 @@ import { manifests as bulkTrashManifests } from './entity-actions/bulk-trash/man import { manifests as collectionManifests } from './collection/manifests.js'; import { manifests as deleteManifests } from './entity-actions/delete/manifests.js'; import { manifests as trashManifests } from './entity-actions/trash/manifests.js'; +import { manifests as workspaceInfoAppManifests } from './reference/workspace-info-app/manifests.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; export const manifests: Array = [ @@ -11,4 +12,5 @@ export const manifests: Array = ...collectionManifests, ...deleteManifests, ...trashManifests, + ...workspaceInfoAppManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/document-references-workspace-view-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts similarity index 52% rename from src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/document-references-workspace-view-info.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts index 61910918bb..89a1bbaf4d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/document-references-workspace-view-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts @@ -1,14 +1,25 @@ -import { UmbDocumentReferenceRepository } from '../repository/index.js'; -import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from '../../constants.js'; -import { css, customElement, html, nothing, repeat, state } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbEntityReferenceRepository, UmbReferenceItemModel } from '../types.js'; +import type { ManifestWorkspaceInfoAppEntityReferencesKind } from './types.js'; +import { css, customElement, html, nothing, property, repeat, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { UmbReferenceItemModel } from '@umbraco-cms/backoffice/relations'; import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { UMB_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; +import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry'; + +@customElement('umb-entity-references-workspace-info-app') +export class UmbEntityReferencesWorkspaceInfoAppElement extends UmbLitElement { + @property({ type: Object }) + private _manifest?: ManifestWorkspaceInfoAppEntityReferencesKind | undefined; + public get manifest(): ManifestWorkspaceInfoAppEntityReferencesKind | undefined { + return this._manifest; + } + public set manifest(value: ManifestWorkspaceInfoAppEntityReferencesKind | undefined) { + this._manifest = value; + this.#init(); + } -@customElement('umb-document-references-workspace-info-app') -export class UmbDocumentReferencesWorkspaceInfoAppElement extends UmbLitElement { @state() private _currentPage = 1; @@ -19,47 +30,62 @@ export class UmbDocumentReferencesWorkspaceInfoAppElement extends UmbLitElement private _items?: Array = []; #itemsPerPage = 10; - #referenceRepository = new UmbDocumentReferenceRepository(this); - #documentUnique?: UmbEntityUnique; - #workspaceContext?: typeof UMB_DOCUMENT_WORKSPACE_CONTEXT.TYPE; + #referenceRepository?: UmbEntityReferenceRepository; + #unique?: UmbEntityUnique; + #workspaceContext?: typeof UMB_ENTITY_WORKSPACE_CONTEXT.TYPE; constructor() { super(); - this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (context) => { + this.consumeContext(UMB_ENTITY_WORKSPACE_CONTEXT, (context) => { this.#workspaceContext = context; - this.#observeDocumentUnique(); + this.#observeUnique(); }); } - #observeDocumentUnique() { + async #init() { + if (!this._manifest) return; + const referenceRepositoryAlias = this._manifest.meta.referenceRepositoryAlias; + + if (!referenceRepositoryAlias) { + throw new Error('Reference repository alias is required'); + } + + this.#referenceRepository = await createExtensionApiByAlias( + this, + referenceRepositoryAlias, + ); + + this.#getReferences(); + } + + #observeUnique() { this.observe( this.#workspaceContext?.unique, (unique) => { if (!unique) { - this.#documentUnique = undefined; + this.#unique = undefined; this._items = []; return; } - if (this.#documentUnique === unique) { + if (this.#unique === unique) { return; } - this.#documentUnique = unique; + this.#unique = unique; this.#getReferences(); }, - 'umbReferencesDocumentUniqueObserver', + 'umbEntityReferencesUniqueObserver', ); } async #getReferences() { - if (!this.#documentUnique) { - throw new Error('Document unique is required'); - } + if (!this.#unique) return; + if (!this.#referenceRepository) return; const { data } = await this.#referenceRepository.requestReferencedBy( - this.#documentUnique, + this.#unique, (this._currentPage - 1) * this.#itemsPerPage, this.#itemsPerPage, ); @@ -105,7 +131,7 @@ export class UmbDocumentReferencesWorkspaceInfoAppElement extends UmbLitElement if (totalPages <= 1) return nothing; return html` -
- ${this._templateQuery?.resultCount ?? 0} - items returned, in - ${this._templateQuery?.executionTime ?? 0} ms + items returned, in ${this._templateQuery?.sampleResults.map( (sample) => html`${sample.name}`, diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts index 513abc2ffc..047b6c0589 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts @@ -1,4 +1,5 @@ import { css, customElement, html, ifDefined, property, repeat, when } from '@umbraco-cms/backoffice/external/lit'; +import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; import { UUIPopoverContainerElement } from '@umbraco-cms/backoffice/external/uui'; export type UmbCascadingMenuItem = { @@ -12,7 +13,7 @@ export type UmbCascadingMenuItem = { }; @customElement('umb-cascading-menu-popover') -export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { +export class UmbCascadingMenuPopoverElement extends UmbElementMixin(UUIPopoverContainerElement) { @property({ type: Array }) items?: Array; @@ -70,6 +71,8 @@ export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { element.setAttribute('popovertarget', popoverId); } + const label = this.localize.string(item.label); + return html`
this.#onMouseEnter(item, popoverId)} @@ -80,11 +83,12 @@ export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { () => html` this.#onClick(item, popoverId)}> ${when(item.icon, (icon) => html``)} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts index 33fc3116cf..44ffd238b2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts @@ -61,7 +61,7 @@ export class UmbTiptapToolbarElement extends UmbLitElement { }, undefined, undefined, - () => import('../toolbar/default-tiptap-toolbar-element.api.js'), + () => import('../toolbar/default-tiptap-toolbar-api.js'), ); this.#extensionsController.apiProperties = { configuration: this.configuration }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-element.api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-api.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-element.api.ts rename to src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-api.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/style-menu.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/style-menu.tiptap-toolbar-api.ts new file mode 100644 index 0000000000..797c382aec --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/style-menu.tiptap-toolbar-api.ts @@ -0,0 +1,34 @@ +import { UmbTiptapToolbarElementApiBase } from '../../extensions/base.js'; +import type { MetaTiptapToolbarStyleMenuItem } from '../../extensions/types.js'; +import type { ChainedCommands, Editor } from '@umbraco-cms/backoffice/external/tiptap'; + +export default class UmbTiptapToolbarStyleMenuApi extends UmbTiptapToolbarElementApiBase { + #commands: Record ChainedCommands }> = { + h1: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 1 }) }, + h2: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 2 }) }, + h3: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 3 }) }, + h4: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 4 }) }, + h5: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 5 }) }, + h6: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 6 }) }, + p: { type: 'paragraph', command: (chain) => chain.setParagraph() }, + blockquote: { type: 'blockquote', command: (chain) => chain.toggleBlockquote() }, + code: { type: 'code', command: (chain) => chain.toggleCode() }, + codeBlock: { type: 'codeBlock', command: (chain) => chain.toggleCodeBlock() }, + div: { type: 'div', command: (chain) => chain.toggleNode('div', 'paragraph') }, + em: { type: 'italic', command: (chain) => chain.setItalic() }, + ol: { type: 'orderedList', command: (chain) => chain.toggleOrderedList() }, + strong: { type: 'bold', command: (chain) => chain.setBold() }, + s: { type: 'strike', command: (chain) => chain.setStrike() }, + span: { type: 'span', command: (chain) => chain.toggleMark('span') }, + u: { type: 'underline', command: (chain) => chain.setUnderline() }, + ul: { type: 'bulletList', command: (chain) => chain.toggleBulletList() }, + }; + + override execute(editor?: Editor, item?: MetaTiptapToolbarStyleMenuItem) { + if (!editor || !item?.data) return; + const { tag, id, class: className } = item.data; + const focus = editor.chain().focus(); + const ext = tag ? this.#commands[tag] : null; + (ext?.command?.(focus) ?? focus).setId(id, ext?.type).setClassName(className, ext?.type).run(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-color-picker-button.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-color-picker-button.element.ts index ff19362aec..07b173accc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-color-picker-button.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-color-picker-button.element.ts @@ -38,7 +38,7 @@ export class UmbTiptapToolbarColorPickerButtonElement extends UmbTiptapToolbarBu - + diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-menu.element.ts index 9576445ecf..7f7726eb10 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-menu.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-menu.element.ts @@ -49,8 +49,9 @@ export class UmbTiptapToolbarMenuElement extends UmbLitElement { } async #setMenu() { - if (!this.#manifest?.meta.items) return; - this.#menu = await this.#getMenuItems(this.#manifest.meta.items); + const items = this.#manifest?.items ?? this.#manifest?.meta.items; + if (!items) return; + this.#menu = await this.#getMenuItems(items); } async #getMenuItems(items: Array): Promise> { @@ -92,10 +93,10 @@ export class UmbTiptapToolbarMenuElement extends UmbLitElement { } return { - icon: item.icon, + icon: item.appearance?.icon ?? item.icon, items, label: item.label, - style: item.style, + style: item.appearance?.style ?? item.style, separatorAfter: item.separatorAfter, element, execute: () => this.api?.execute(this.editor, item), diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts index cb3b5c79ea..06305ea6bd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts @@ -33,6 +33,16 @@ const kinds: Array = [ element: () => import('../components/toolbar/tiptap-toolbar-menu.element.js'), }, }, + { + type: 'kind', + alias: 'Umb.Kind.TiptapToolbar.StyleMenu', + matchKind: 'styleMenu', + matchType: 'tiptapToolbarExtension', + manifest: { + api: () => import('../components/toolbar/style-menu.tiptap-toolbar-api.js'), + element: () => import('../components/toolbar/tiptap-toolbar-menu.element.js'), + }, + }, ]; const coreExtensions: Array = [ @@ -581,17 +591,17 @@ const toolbarExtensions: Array = [ alias: 'Umb.Tiptap.Toolbar.FontFamily', name: 'Font Family Tiptap Extension', api: () => import('./toolbar/font-family.tiptap-toolbar-api.js'), + items: [ + { label: 'Sans serif', appearance: { style: 'font-family: sans-serif;' }, data: 'sans-serif' }, + { label: 'Serif', appearance: { style: 'font-family: serif;' }, data: 'serif' }, + { label: 'Monospace', appearance: { style: 'font-family: monospace;' }, data: 'monospace' }, + { label: 'Cursive', appearance: { style: 'font-family: cursive;' }, data: 'cursive' }, + { label: 'Fantasy', appearance: { style: 'font-family: fantasy;' }, data: 'fantasy' }, + ], meta: { alias: 'umbFontFamily', icon: 'icon-ruler-alt', label: 'Font family', - items: [ - { label: 'Sans serif', style: 'font-family: sans-serif;', data: 'sans-serif' }, - { label: 'Serif', style: 'font-family: serif;', data: 'serif' }, - { label: 'Monospace', style: 'font-family: monospace;', data: 'monospace' }, - { label: 'Cursive', style: 'font-family: cursive;', data: 'cursive' }, - { label: 'Fantasy', style: 'font-family: fantasy;', data: 'fantasy' }, - ], }, }, { @@ -600,21 +610,21 @@ const toolbarExtensions: Array = [ alias: 'Umb.Tiptap.Toolbar.FontSize', name: 'Font Size Tiptap Extension', api: () => import('./toolbar/font-size.tiptap-toolbar-api.js'), + items: [ + { label: '8pt', data: '8pt;' }, + { label: '10pt', data: '10pt;' }, + { label: '12pt', data: '12pt;' }, + { label: '14pt', data: '14pt;' }, + { label: '16pt', data: '16pt;' }, + { label: '18pt', data: '18pt;' }, + { label: '24pt', data: '24pt;' }, + { label: '26pt', data: '26pt;' }, + { label: '48pt', data: '48pt;' }, + ], meta: { alias: 'umbFontSize', icon: 'icon-ruler', label: 'Font size', - items: [ - { label: '8pt', data: '8pt;' }, - { label: '10pt', data: '10pt;' }, - { label: '12pt', data: '12pt;' }, - { label: '14pt', data: '14pt;' }, - { label: '16pt', data: '16pt;' }, - { label: '18pt', data: '18pt;' }, - { label: '24pt', data: '24pt;' }, - { label: '26pt', data: '26pt;' }, - { label: '48pt', data: '48pt;' }, - ], }, }, { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts index 915c7c939e..60c0894e84 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts @@ -1,35 +1,54 @@ export const manifests: Array = [ { type: 'tiptapToolbarExtension', - kind: 'menu', + kind: 'styleMenu', alias: 'Umb.Tiptap.Toolbar.StyleSelect', name: 'Style Select Tiptap Extension', - api: () => import('./style-select.tiptap-toolbar-api.js'), + items: [ + { + label: 'Headers', + items: [ + { + label: 'Page header', + appearance: { icon: 'icon-heading-2', style: 'font-size: x-large;font-weight: bold;' }, + data: { tag: 'h2' }, + }, + { + label: 'Section header', + appearance: { icon: 'icon-heading-3', style: 'font-size: large;font-weight: bold;' }, + data: { tag: 'h3' }, + }, + { + label: 'Paragraph header', + appearance: { icon: 'icon-heading-4', style: 'font-weight: bold;' }, + data: { tag: 'h4' }, + }, + ], + }, + { + label: 'Blocks', + items: [{ label: 'Paragraph', appearance: { icon: 'icon-paragraph' }, data: { tag: 'p' } }], + }, + { + label: 'Containers', + items: [ + { + label: 'Block quote', + appearance: { icon: 'icon-blockquote', style: 'font-style: italic;' }, + data: { tag: 'blockquote' }, + }, + { + label: 'Code block', + appearance: { icon: 'icon-code', style: 'font-family: monospace;' }, + data: { tag: 'codeBlock' }, + }, + ], + }, + ], meta: { alias: 'umbStyleSelect', icon: 'icon-palette', label: 'Style Select', - items: [ - { - label: 'Headers', - items: [ - { label: 'Page header', data: 'h2', style: 'font-size: x-large;font-weight: bold;' }, - { label: 'Section header', data: 'h3', style: 'font-size: large;font-weight: bold;' }, - { label: 'Paragraph header', data: 'h4', style: 'font-weight: bold;' }, - ], - }, - { - label: 'Blocks', - items: [{ label: 'Paragraph', data: 'p' }], - }, - { - label: 'Containers', - items: [ - { label: 'Quote', data: 'blockquote', style: 'font-style: italic;' }, - { label: 'Code', data: 'codeBlock', style: 'font-family: monospace;' }, - ], - }, - ], }, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select.tiptap-toolbar-api.ts index 3c65eed2f8..d67746a412 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select.tiptap-toolbar-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select.tiptap-toolbar-api.ts @@ -1,20 +1,4 @@ -import { UmbTiptapToolbarElementApiBase } from '../base.js'; -import type { MetaTiptapToolbarMenuItem } from '../types.js'; -import type { Editor } from '@umbraco-cms/backoffice/external/tiptap'; +import UmbTiptapToolbarStyleMenuApi from '../../components/toolbar/style-menu.tiptap-toolbar-api.js'; -export default class UmbTiptapToolbarStyleSelectExtensionApi extends UmbTiptapToolbarElementApiBase { - #commands: Record void> = { - h2: (editor) => editor?.chain().focus().toggleHeading({ level: 2 }).run(), - h3: (editor) => editor?.chain().focus().toggleHeading({ level: 3 }).run(), - h4: (editor) => editor?.chain().focus().toggleHeading({ level: 4 }).run(), - p: (editor) => editor?.chain().focus().setParagraph().run(), - blockquote: (editor) => editor?.chain().focus().toggleBlockquote().run(), - codeBlock: (editor) => editor?.chain().focus().toggleCodeBlock().run(), - }; - - override execute(editor?: Editor, item?: MetaTiptapToolbarMenuItem) { - if (!item?.data) return; - const key = item.data.toString(); - this.#commands[key](editor); - } -} +/** @deprecated No longer used internally. This class will be removed in Umbraco 17. [LK] */ +export default class UmbTiptapToolbarStyleSelectExtensionApi extends UmbTiptapToolbarStyleMenuApi {} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap-toolbar.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap-toolbar.extension.ts index 35432bca51..9370648134 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap-toolbar.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap-toolbar.extension.ts @@ -30,27 +30,40 @@ export interface ManifestTiptapToolbarExtensionColorPickerButtonKind< kind: 'colorPickerButton'; } -export interface MetaTiptapToolbarMenuItem { - data?: unknown; +export interface MetaTiptapToolbarMenuItem { + appearance?: { icon?: string; style?: string }; + data?: ItemDataType; element?: ElementLoaderProperty; elementName?: string; + /** @deprecated No longer used, please use `appearance: { icon }`. This will be removed in Umbraco 17. [LK] */ icon?: string; - items?: Array; + items?: Array>; label: string; separatorAfter?: boolean; + /** @deprecated No longer used, please use `appearance: { style }`. This will be removed in Umbraco 17. [LK] */ style?: string; } export interface MetaTiptapToolbarMenuExtension extends MetaTiptapToolbarExtension { look?: 'icon' | 'text'; - items: Array; + /** @deprecated No longer used, please use `items` at the root manifest. This will be removed in Umbraco 17. [LK] */ + items?: Array; } -export interface ManifestTiptapToolbarExtensionMenuKind< - MetaType extends MetaTiptapToolbarMenuExtension = MetaTiptapToolbarMenuExtension, -> extends ManifestTiptapToolbarExtension { +export interface ManifestTiptapToolbarExtensionMenuKind + extends ManifestTiptapToolbarExtension { type: 'tiptapToolbarExtension'; kind: 'menu'; + items?: Array; +} + +export type MetaTiptapToolbarStyleMenuItem = MetaTiptapToolbarMenuItem<{ tag?: string; class?: string; id?: string }>; + +export interface ManifestTiptapToolbarExtensionStyleMenuKind + extends ManifestTiptapToolbarExtension { + type: 'tiptapToolbarExtension'; + kind: 'styleMenu'; + items: Array; } declare global { @@ -59,6 +72,7 @@ declare global { | ManifestTiptapToolbarExtension | ManifestTiptapToolbarExtensionButtonKind | ManifestTiptapToolbarExtensionColorPickerButtonKind - | ManifestTiptapToolbarExtensionMenuKind; + | ManifestTiptapToolbarExtensionMenuKind + | ManifestTiptapToolbarExtensionStyleMenuKind; } } diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockGridBlocks.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockGridBlocks.spec.ts index 1bd501f5e3..a151c4573e 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockGridBlocks.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/Clipboard/ClipboardBlockGridBlocks.spec.ts @@ -334,3 +334,29 @@ test('can not copy a block from a block grid to a block list without allowed blo // Clean await umbracoApi.documentType.ensureNameNotExists(blockListElementTypeName); }); + +test('can not copy a block from a block grid to root without allowed in root', async ({umbracoApi, umbracoUi}) => { + // Arrange + const secondElementTypeName = 'SecondElementType'; + const areaAlias = 'testArea'; + await umbracoApi.documentType.ensureNameNotExists(secondElementTypeName); + const secondElementTypeId = await umbracoApi.documentType.createEmptyElementType(secondElementTypeName); + const blockGridId = await umbracoApi.dataType.createBlockGridWithAnAreaInABlockWithAllowInAreasAndASecondBlock(blockGridDataTypeName, elementTypeId, secondElementTypeId, areaAlias, true, 'TestCreateLabel' ,12 ,1, 0 , 10, false, true); + const areaKey = await umbracoApi.dataType.getBlockGridAreaKeyFromBlock(blockGridDataTypeName, elementTypeId, areaAlias); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, blockGridDataTypeName, blockGridId, groupName); + await umbracoApi.document.createDocumentWithABlockGridEditorWithABlockThatContainsABlockInAnArea(contentName, documentTypeId, blockGridDataTypeName, elementTypeId, areaKey, secondElementTypeId, AliasHelper.toAlias(elementPropertyName), blockPropertyValue, richTextDataTypeUiAlias); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + await umbracoUi.content.goToContentWithName(contentName); + + // Act + await umbracoUi.content.clickCopyBlockGridBlockButton(groupName, blockGridDataTypeName, secondElementTypeName, 1); + await umbracoUi.content.clickActionsMenuForProperty(groupName, blockGridDataTypeName); + await umbracoUi.content.clickExactReplaceButton(); + + // Assert + await umbracoUi.content.doesClipboardContainCopiedBlocksCount(0); + + // Clean + await umbracoApi.documentType.ensureNameNotExists(secondElementTypeName); +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithApprovedColor.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithApprovedColor.spec.ts index a50279788f..cfe7aaeb15 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithApprovedColor.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithApprovedColor.spec.ts @@ -63,7 +63,7 @@ test('can create content with the custom approved color data type', async ({umbr const customDataTypeName = 'CustomApprovedColor'; const colorValue = 'd73737'; const colorLabel = 'Test Label'; - const customDataTypeId = await umbracoApi.dataType.createDefaultApprovedColorDataTypeWithOneItem(customDataTypeName, colorLabel, colorValue); + const customDataTypeId = await umbracoApi.dataType.createApprovedColorDataTypeWithOneItem(customDataTypeName, colorLabel, colorValue); const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId); await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); await umbracoUi.content.goToSection(ConstantHelper.sections.content); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithUploadVectorGraphics.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithUploadVectorGraphics.spec.ts index 5142d5741e..4498f0f4d7 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithUploadVectorGraphics.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithUploadVectorGraphics.spec.ts @@ -80,7 +80,7 @@ test(`can upload a file with the svg extension in the content`, async ({umbracoA const contentData = await umbracoApi.document.getByName(contentName); expect(contentData.values[0].alias).toEqual(AliasHelper.toAlias(dataTypeName)); expect(contentData.values[0].value.src).toContain(AliasHelper.toAlias(vectorGraphicsName)); - await umbracoUi.content.doesUploadedSvgThumbnailHaveSrc(contentData.values[0].value.src); + await umbracoUi.content.doesUploadedSvgThumbnailHaveSrc(umbracoApi.baseUrl + contentData.values[0].value.src); }); test('can remove an svg file in the content', async ({umbracoApi, umbracoUi}) => { diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/RenderingContent/RenderingContentWithApprovedColor.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/RenderingContent/RenderingContentWithApprovedColor.spec.ts index bd645179ee..5daad8769b 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/RenderingContent/RenderingContentWithApprovedColor.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/RenderingContent/RenderingContentWithApprovedColor.spec.ts @@ -9,7 +9,7 @@ const colorValue = {label: "Test Label", value: "038c33"}; let dataTypeId = null; test.beforeEach(async ({umbracoApi}) => { - dataTypeId = await umbracoApi.dataType.createDefaultApprovedColorDataTypeWithOneItem(customDataTypeName, colorValue.label, colorValue.value); + dataTypeId = await umbracoApi.dataType.createApprovedColorDataTypeWithOneItem(customDataTypeName, colorValue.label, colorValue.value); }); test.afterEach(async ({umbracoApi}) => { diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/MediaType/MediaTypeFolder.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/MediaType/MediaTypeFolder.spec.ts index f318fd732f..f05279eb9f 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/MediaType/MediaTypeFolder.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/MediaType/MediaTypeFolder.spec.ts @@ -54,6 +54,7 @@ test('can rename a media type folder', async ({umbracoApi, umbracoUi}) => { await umbracoUi.mediaType.clickRootFolderCaretButton(); await umbracoUi.mediaType.clickActionsMenuForName(oldFolderName); await umbracoUi.mediaType.clickRenameFolderButton(); + await umbracoUi.waitForTimeout(500); await umbracoUi.mediaType.enterFolderName(mediaTypeFolderName); await umbracoUi.mediaType.clickConfirmRenameButton(); diff --git a/tests/Umbraco.Tests.Benchmarks/StringExtensionsBenchmarks.cs b/tests/Umbraco.Tests.Benchmarks/StringExtensionsBenchmarks.cs new file mode 100644 index 0000000000..4f4a73c2c6 --- /dev/null +++ b/tests/Umbraco.Tests.Benchmarks/StringExtensionsBenchmarks.cs @@ -0,0 +1,257 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using BenchmarkDotNet.Attributes; +using Umbraco.Cms.Core; + +namespace Umbraco.Tests.Benchmarks; + +[MemoryDiagnoser] +public class StringExtensionsBenchmarks +{ + private static readonly Random _seededRandom = new(60); + private const int Size = 100; + private static readonly string[] _stringsWithCommaSeparatedNumbers = new string[Size]; + + static StringExtensionsBenchmarks() + { + for (var i = 0; i < Size; i++) + { + int countOfNumbers = _seededRandom.Next(2, 10); // guess on path lengths in normal use + int[] randomIds = new int[countOfNumbers]; + for (var i1 = 0; i1 < countOfNumbers; i1++) + { + randomIds[i1] = _seededRandom.Next(-1, int.MaxValue); + } + + _stringsWithCommaSeparatedNumbers[i] = string.Join(',', randomIds); + } + } + + /// + /// Ye olden way of doing it (before 20250201 https://github.com/umbraco/Umbraco-CMS/pull/18048) + /// + /// A number so the compiler/runtime doesn't optimize it away. + [Benchmark] + public int Linq() + { + var totalNumberOfIds = 0; // a number to operate on so it is not optimized away + foreach (string? stringWithCommaSeparatedNumbers in _stringsWithCommaSeparatedNumbers) + { + totalNumberOfIds += Linq(stringWithCommaSeparatedNumbers).Length; + } + + return totalNumberOfIds; + } + + private static int[] Linq(string path) + { + int[]? nodeIds = path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries) + .Select(x => + int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var output) + ? Attempt.Succeed(output) + : Attempt.Fail()) + .Where(x => x.Success) + .Select(x => x.Result) + .Reverse() + .ToArray(); + return nodeIds; + } + + /// + /// Here we are allocating strings to the separated values, + /// BUT we know the count of numbers, so we can allocate the exact size of list we need + /// + /// A number so the compiler/runtime doesn't optimize it away. + [Benchmark] + public int SplitToHeapStrings() + { + var totalNumberOfIds = 0; // a number to operate on so it is not optimized away + foreach (string stringWithCommaSeparatedNumbers in _stringsWithCommaSeparatedNumbers) + { + totalNumberOfIds += SplitToHeapStrings(stringWithCommaSeparatedNumbers).Length; + } + + return totalNumberOfIds; + } + + private static int[] SplitToHeapStrings(string path) + { + string[] pathSegments = path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); + List nodeIds = new(pathSegments.Length); // here we know how large the resulting list should at least be + for (int i = pathSegments.Length - 1; i >= 0; i--) + { + if (int.TryParse(pathSegments[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) + { + nodeIds.Add(pathSegment); + } + } + + return nodeIds.ToArray(); // allocates a new array + } + + /// + /// Here we avoid allocating strings to the separated values, + /// BUT we do not know the count of numbers, so we might end up resizing the list we add numbers to it + /// + /// A number so the compiler/runtime doesn't optimize it away. + [Benchmark] + public int SplitToStackSpansWithoutEmptyCheckReversingListAsSpan() + { + var totalNumberOfIds = 0; // a number to operate on so it is not optimized away + foreach (string stringWithCommaSeparatedNumbers in _stringsWithCommaSeparatedNumbers) + { + totalNumberOfIds += SplitToStackSpansWithoutEmptyCheckReversingListAsSpan(stringWithCommaSeparatedNumbers).Length; + } + + return totalNumberOfIds; + } + + private static int[] SplitToStackSpansWithoutEmptyCheckReversingListAsSpan(string path) + { + ReadOnlySpan pathSpan = path.AsSpan(); + MemoryExtensions.SpanSplitEnumerator pathSegments = pathSpan.Split(Constants.CharArrays.Comma); + + // Here we do NOT know how large the resulting list should at least be + // Default empty List<> internal array capacity on add is currently 4 + // If the count of numbers are less than 4, we overallocate a little + // If the count of numbers are more than 4, the list will be resized, resulting in a copy from initial array to new double size array + // If the count of numbers are more than 8, another new array is allocated and copied to + List nodeIds = []; + foreach (Range rangeOfPathSegment in pathSegments) + { + // this is only a span of the string, a string is not allocated on the heap + ReadOnlySpan pathSegmentSpan = pathSpan[rangeOfPathSegment]; + if (int.TryParse(pathSegmentSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) + { + nodeIds.Add(pathSegment); + } + } + + Span nodeIdsSpan = CollectionsMarshal.AsSpan(nodeIds); + var result = new int[nodeIdsSpan.Length]; + var resultIndex = 0; + for (int i = nodeIdsSpan.Length - 1; i >= 0; i--) + { + result[resultIndex++] = nodeIdsSpan[i]; + } + + return result; + } + + /// + /// Here we avoid allocating strings to the separated values, + /// BUT we do not know the count of numbers, so we might end up resizing the list we add numbers to it + /// + /// A number so the compiler/runtime doesn't optimize it away. + [Benchmark] + public int SplitToStackSpansWithoutEmptyCheck() + { + var totalNumberOfIds = 0; // a number to operate on so it is not optimized away + foreach (string stringWithCommaSeparatedNumbers in _stringsWithCommaSeparatedNumbers) + { + totalNumberOfIds += SplitToStackSpansWithoutEmptyCheck(stringWithCommaSeparatedNumbers).Length; + } + + return totalNumberOfIds; + } + + private static int[] SplitToStackSpansWithoutEmptyCheck(string path) + { + ReadOnlySpan pathSpan = path.AsSpan(); + MemoryExtensions.SpanSplitEnumerator pathSegments = pathSpan.Split(Constants.CharArrays.Comma); + + // Here we do NOT know how large the resulting list should at least be + // Default empty List<> internal array capacity on add is currently 4 + // If the count of numbers are less than 4, we overallocate a little + // If the count of numbers are more than 4, the list will be resized, resulting in a copy from initial array to new double size array + // If the count of numbers are more than 8, another new array is allocated and copied to + List nodeIds = []; + foreach (Range rangeOfPathSegment in pathSegments) + { + // this is only a span of the string, a string is not allocated on the heap + ReadOnlySpan pathSegmentSpan = pathSpan[rangeOfPathSegment]; + if (int.TryParse(pathSegmentSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) + { + nodeIds.Add(pathSegment); + } + } + + var result = new int[nodeIds.Count]; + var resultIndex = 0; + for (int i = nodeIds.Count - 1; i >= 0; i--) + { + result[resultIndex++] = nodeIds[i]; + } + + return result; + } + + /// + /// Here we avoid allocating strings to the separated values, + /// BUT we do not know the count of numbers, so we might end up resizing the list we add numbers to it + /// + /// Here with an empty check, unlikely in umbraco use case. + /// A number so the compiler/runtime doesn't optimize it away. + [Benchmark] + public int SplitToStackSpansWithEmptyCheck() + { + var totalNumberOfIds = 0; // a number to operate on so it is not optimized away + foreach (string stringWithCommaSeparatedNumbers in _stringsWithCommaSeparatedNumbers) + { + totalNumberOfIds += SplitToStackSpansWithEmptyCheck(stringWithCommaSeparatedNumbers).Length; + } + + return totalNumberOfIds; + } + + private static int[] SplitToStackSpansWithEmptyCheck(string path) + { + ReadOnlySpan pathSpan = path.AsSpan(); + MemoryExtensions.SpanSplitEnumerator pathSegments = pathSpan.Split(Constants.CharArrays.Comma); + + // Here we do NOT know how large the resulting list should at least be + // Default empty List<> internal array capacity on add is currently 4 + // If the count of numbers are less than 4, we overallocate a little + // If the count of numbers are more than 4, the list will be resized, resulting in a copy from initial array to new double size array + // If the count of numbers are more than 8, another new array is allocated and copied to + List nodeIds = []; + foreach (Range rangeOfPathSegment in pathSegments) + { + // this is only a span of the string, a string is not allocated on the heap + ReadOnlySpan pathSegmentSpan = pathSpan[rangeOfPathSegment]; + if (pathSegmentSpan.IsEmpty) + { + continue; + } + + if (int.TryParse(pathSegmentSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) + { + nodeIds.Add(pathSegment); + } + } + + var result = new int[nodeIds.Count]; + var resultIndex = 0; + for (int i = nodeIds.Count - 1; i >= 0; i--) + { + result[resultIndex++] = nodeIds[i]; + } + + return result; + } + +// BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.2894) +// Intel Core i7-10750H CPU 2.60GHz, 1 CPU, 12 logical and 6 physical cores +// .NET Core SDK 3.1.426 [C:\Program Files\dotnet\sdk] +// [Host] : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2 +// +// Toolchain=InProcessEmitToolchain +// +// | Method | Mean | Error | StdDev | Gen0 | Allocated | +// |------------------------------------------------------ |---------:|---------:|---------:|-------:|----------:| +// | Linq | 46.39 us | 0.515 us | 0.430 us | 9.4604 | 58.31 KB | +// | SplitToHeapStrings | 30.28 us | 0.310 us | 0.275 us | 7.0801 | 43.55 KB | +// | SplitToStackSpansWithoutEmptyCheckReversingListAsSpan | 20.47 us | 0.290 us | 0.257 us | 2.7161 | 16.73 KB | +// | SplitToStackSpansWithoutEmptyCheck | 20.60 us | 0.315 us | 0.280 us | 2.7161 | 16.73 KB | +// | SplitToStackSpansWithEmptyCheck | 20.57 us | 0.308 us | 0.288 us | 2.7161 | 16.73 KB | +} diff --git a/tests/Umbraco.Tests.Common/Builders/ContentTypeBaseBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentTypeBaseBuilder.cs index a5d3597662..e01d87aa0e 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentTypeBaseBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentTypeBaseBuilder.cs @@ -1,8 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; -using System.Collections.Generic; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Tests.Common.Builders.Extensions; @@ -29,7 +27,8 @@ public abstract class ContentTypeBaseBuilder IWithIconBuilder, IWithThumbnailBuilder, IWithTrashedBuilder, - IWithIsContainerBuilder + IWithIsContainerBuilder, + IWithAllowAsRootBuilder where TParent : IBuildContentTypes { private string _alias; @@ -49,6 +48,7 @@ public abstract class ContentTypeBaseBuilder private string _thumbnail; private bool? _trashed; private DateTime? _updateDate; + private bool? _allowedAtRoot; public ContentTypeBaseBuilder(TParent parentBuilder) : base(parentBuilder) @@ -168,6 +168,12 @@ public abstract class ContentTypeBaseBuilder set => _updateDate = value; } + bool? IWithAllowAsRootBuilder.AllowAsRoot + { + get => _allowedAtRoot; + set => _allowedAtRoot = value; + } + protected int GetId() => _id ?? 0; protected Guid GetKey() => _key ?? Guid.NewGuid(); @@ -202,6 +208,8 @@ public abstract class ContentTypeBaseBuilder protected Guid? GetListView() => _listView; + protected bool GetAllowedAtRoot() => _allowedAtRoot ?? false; + protected void BuildPropertyGroups(ContentTypeCompositionBase contentType, IEnumerable propertyGroups) { foreach (var propertyGroup in propertyGroups) diff --git a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs index 65c2b85d79..54e9090ddb 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs @@ -1,8 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; @@ -125,6 +123,7 @@ public class ContentTypeBuilder contentType.Trashed = GetTrashed(); contentType.ListView = GetListView(); contentType.IsElement = _isElement ?? false; + contentType.AllowedAsRoot = GetAllowedAtRoot(); contentType.HistoryCleanup = new HistoryCleanup(); contentType.Variations = contentVariation; diff --git a/tests/Umbraco.Tests.Common/Builders/MediaTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/MediaTypeBuilder.cs index dcde82b47d..6439899955 100644 --- a/tests/Umbraco.Tests.Common/Builders/MediaTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/MediaTypeBuilder.cs @@ -139,6 +139,7 @@ public class MediaTypeBuilder var mediaType = builder .WithAlias(alias) .WithName(name) + .WithIcon("icon-picture") .WithParentContentType(parent) .AddPropertyGroup() .WithAlias(propertyGroupAlias) diff --git a/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs b/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs index c84b118abe..2d78198649 100644 --- a/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs @@ -3,6 +3,7 @@ using Moq; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Models.Membership.Permissions; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Builders.Interfaces; @@ -27,6 +28,8 @@ public class UserGroupBuilder { private string _alias; private IEnumerable _allowedSections = Enumerable.Empty(); + private IEnumerable _allowedLanguages = Enumerable.Empty(); + private IEnumerable _granularPermissions = Enumerable.Empty(); private string _icon; private int? _id; private Guid? _key; @@ -95,13 +98,24 @@ public class UserGroupBuilder return this; } - public UserGroupBuilder WithAllowedSections(IList allowedSections) { _allowedSections = allowedSections; return this; } + public UserGroupBuilder WithAllowedLanguages(IList allowedLanguages) + { + _allowedLanguages = allowedLanguages; + return this; + } + + public UserGroupBuilder WithGranularPermissions(IList granularPermissions) + { + _granularPermissions = granularPermissions; + return this; + } + public UserGroupBuilder WithStartContentId(int startContentId) { _startContentId = startContentId; @@ -144,17 +158,40 @@ public class UserGroupBuilder Id = id, Key = key, StartContentId = startContentId, - StartMediaId = startMediaId + StartMediaId = startMediaId, + Permissions = _permissions }; - userGroup.Permissions = _permissions; + BuildAllowedSections(userGroup); + BuildAllowedLanguages(userGroup); + BuildGranularPermissions(userGroup); + return userGroup; + } + + + private void BuildAllowedSections(UserGroup userGroup) + { foreach (var section in _allowedSections) { userGroup.AddAllowedSection(section); } + } - return userGroup; + private void BuildAllowedLanguages(UserGroup userGroup) + { + foreach (var language in _allowedLanguages) + { + userGroup.AddAllowedLanguage(language); + } + } + + private void BuildGranularPermissions(UserGroup userGroup) + { + foreach (var permission in _granularPermissions) + { + userGroup.GranularPermissions.Add(permission); + } } public static UserGroup CreateUserGroup( diff --git a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml index 9a446cdb11..c3bf60b3cb 100644 --- a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml +++ b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml @@ -78,6 +78,20 @@ lib/net9.0/Umbraco.Tests.Integration.dll true + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.DocumentNavigationServiceTests.Bin_Structure_Can_Rebuild + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.DocumentNavigationServiceTests.Structure_Can_Rebuild + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + CP0002 M:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.UserServiceCrudTests.Cannot_Request_Disabled_If_Hidden(Umbraco.Cms.Core.Models.Membership.UserState) @@ -92,6 +106,13 @@ lib/net9.0/Umbraco.Tests.Integration.dll true + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.EntityServiceTests.CreateTestData + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + CP0002 M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.MemberEditingServiceTests.Cannot_Change_IsApproved_Without_Access @@ -113,4 +134,11 @@ lib/net9.0/Umbraco.Tests.Integration.dll true + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.TrackedReferencesServiceTests.Does_not_return_references_if_item_is_not_referenced + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + \ No newline at end of file diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs new file mode 100644 index 0000000000..a6ab1ff131 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs @@ -0,0 +1,85 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceMediaTests +{ + [Test] + public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1"].Id, _mediaByName["5"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Media, + contentStartNodeIds) + .ToArray(); + + // expected total is 2, because only two items at root ("1" amd "10") are allowed + Assert.AreEqual(2, roots.Length); + Assert.Multiple(() => + { + // first and last content items are the ones allowed + Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_mediaByName["5"].Key, roots[1].Entity.Key); + + // explicitly verify the entity sort order, both so we know sorting works, + // and so we know it's actually the first and last item at root + Assert.AreEqual(0, roots[0].Entity.SortOrder); + Assert.AreEqual(4, roots[1].Entity.SortOrder); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(roots[0].HasAccess); + Assert.IsTrue(roots[1].HasAccess); + }); + } + + [Test] + public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_AsNotAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1-3"].Id, _mediaByName["3-3"].Id, _mediaByName["5-3"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Media, + contentStartNodeIds) + .ToArray(); + + Assert.AreEqual(3, roots.Length); + Assert.Multiple(() => + { + // the three start nodes are the children of the "1", "3" and "5" roots, respectively, so these are expected as roots + Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_mediaByName["3"].Key, roots[1].Entity.Key); + Assert.AreEqual(_mediaByName["5"].Key, roots[2].Entity.Key); + + // all are disallowed - only the children (the actual start nodes) are allowed + Assert.IsTrue(roots.All(r => r.HasAccess is false)); + }); + } + + [Test] + public async Task RootUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildRoots_AsNotAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1-2-3"].Id, _mediaByName["2-3-4"].Id, _mediaByName["3-4-5"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Media, + contentStartNodeIds) + .ToArray(); + + Assert.AreEqual(3, roots.Length); + Assert.Multiple(() => + { + // the three start nodes are the grandchildren of the "1", "2" and "3" roots, respectively, so these are expected as roots + Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_mediaByName["2"].Key, roots[1].Entity.Key); + Assert.AreEqual(_mediaByName["3"].Key, roots[2].Entity.Key); + + // all are disallowed - only the grandchildren (the actual start nodes) are allowed + Assert.IsTrue(roots.All(r => r.HasAccess is false)); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs new file mode 100644 index 0000000000..4df5ecbab7 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs @@ -0,0 +1,336 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceMediaTests +{ + [Test] + public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFirstPage_AsAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-1"].Id, _mediaByName["1-10"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + // expected total is 2, because only two items under "1" are allowed (note the page size is 3 for good measure) + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // first and last media items are the ones allowed + Assert.AreEqual(_mediaByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["1-10"].Key, children[1].Entity.Key); + + // explicitly verify the entity sort order, both so we know sorting works, + // and so we know it's actually the first and last item below "1" + Assert.AreEqual(0, children[0].Entity.SortOrder); + Assert.AreEqual(9, children[1].Entity.SortOrder); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInScope() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id, _mediaByName["2-10"].Id); + Assert.AreEqual(2, mediaStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["2"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(1, totalItems); + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + // only the "2-10" media item is returned, as "1-5" is out of scope + Assert.AreEqual(_mediaByName["2-10"].Key, children[0].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id, _mediaByName["2-10"].Id); + Assert.AreEqual(2, mediaStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(0, totalItems); + Assert.AreEqual(0, children.Length); + } + + [Test] + public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginate() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths( + _mediaByName["1-1"].Id, + _mediaByName["1-3"].Id, + _mediaByName["1-5"].Id, + _mediaByName["1-7"].Id, + _mediaByName["1-9"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1"].Key, + 0, + 2, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is 2 + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["1-3"].Key, children[1].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + + // next result page + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1"].Key, + 2, + 2, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is still 2 + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName["1-5"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["1-7"].Key, children[1].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + + // next result page + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1"].Key, + 4, + 2, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is still 2, but this is the last result page + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName["1-9"].Key, children[0].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchildren_AsAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3-3"].Key, + 0, + 100, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + Assert.AreEqual(5, children.Length); + Assert.Multiple(() => + { + // all children of "3-3" should be allowed because "3-3" is a start node + foreach (var childNumber in Enumerable.Range(1, 5)) + { + var child = children[childNumber - 1]; + Assert.AreEqual(_mediaByName[$"3-3-{childNumber}"].Key, child.Entity.Key); + Assert.IsTrue(child.HasAccess); + } + }); + } + + [Test] + public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildren_AsAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3-3"].Id, _mediaByName["3-3-4"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3-3"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3-3" - that is, the actual start nodes + Assert.AreEqual(_mediaByName["3-3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["3-3-4"].Key, children[1].Entity.Key); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsChildren_AsNotAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3-3"].Id, _mediaByName["3-5-3"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3" - that is, the parents of the actual start nodes + Assert.AreEqual(_mediaByName["3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["3-5"].Key, children[1].Entity.Key); + + // both are disallowed - only the two children (the actual start nodes) are allowed + Assert.IsFalse(children[0].HasAccess); + Assert.IsFalse(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild() + { + // NOTE: this test proves that start node paths are *not* additive when structural inheritance is in play. + // if one has a start node that is a descendant to another start node, the descendant start node "wins" + // and the ancestor start node is ignored. this differs somewhat from the norm; we normally consider + // permissions additive (which in this case would mean ignoring the descendant rather than the ancestor). + + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id, _mediaByName["3-3-1"].Id, _mediaByName["3-3-5"].Id); + Assert.AreEqual(2, mediaStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + Assert.AreEqual(1, totalItems); + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName["3-3"].Key, children[0].Entity.Key); + Assert.IsFalse(children[0].HasAccess); + }); + + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3-3"].Key, + 0, + 10, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3-3" - that is, the actual start nodes + Assert.AreEqual(_mediaByName["3-3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["3-3-5"].Key, children[1].Entity.Key); + + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectResultOrder() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id, _mediaByName["3-2"].Id, _mediaByName["3-1"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + Assert.AreEqual(3, totalItems); + Assert.AreEqual(3, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName["3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["3-2"].Key, children[1].Entity.Key); + Assert.AreEqual(_mediaByName["3-3"].Key, children[2].Entity.Key); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.cs new file mode 100644 index 0000000000..44a27bd7fb --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +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.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] +public partial class UserStartNodeEntitiesServiceMediaTests : UmbracoIntegrationTest +{ + private Dictionary _mediaByName = new (); + private IUserGroup _userGroup; + + private IMediaService MediaService => GetRequiredService(); + + private IMediaTypeService MediaTypeService => GetRequiredService(); + + private IUserGroupService UserGroupService => GetRequiredService(); + + private IUserService UserService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + private IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService(); + + protected readonly Ordering BySortOrder = Ordering.By("sortOrder"); + + protected override void ConfigureTestServices(IServiceCollection services) + { + base.ConfigureTestServices(services); + services.AddTransient(); + } + + [SetUp] + public async Task SetUpTestAsync() + { + if (_mediaByName.Any()) + { + return; + } + + var mediaType = new MediaTypeBuilder() + .WithAlias("theMediaType") + .Build(); + mediaType.AllowedAsRoot = true; + await MediaTypeService.CreateAsync(mediaType, Constants.Security.SuperUserKey); + mediaType.AllowedContentTypes = [new() { Alias = mediaType.Alias, Key = mediaType.Key }]; + await MediaTypeService.UpdateAsync(mediaType, Constants.Security.SuperUserKey); + + foreach (var rootNumber in Enumerable.Range(1, 5)) + { + var root = new MediaBuilder() + .WithMediaType(mediaType) + .WithName($"{rootNumber}") + .Build(); + MediaService.Save(root); + _mediaByName[root.Name!] = root; + + foreach (var childNumber in Enumerable.Range(1, 10)) + { + var child = new MediaBuilder() + .WithMediaType(mediaType) + .WithName($"{rootNumber}-{childNumber}") + .Build(); + child.SetParent(root); + MediaService.Save(child); + _mediaByName[child.Name!] = child; + + foreach (var grandChildNumber in Enumerable.Range(1, 5)) + { + var grandchild = new MediaBuilder() + .WithMediaType(mediaType) + .WithName($"{rootNumber}-{childNumber}-{grandChildNumber}") + .Build(); + grandchild.SetParent(child); + MediaService.Save(grandchild); + _mediaByName[grandchild.Name!] = grandchild; + } + } + } + + _userGroup = new UserGroupBuilder() + .WithAlias("theGroup") + .WithAllowedSections(["media"]) + .Build(); + _userGroup.StartMediaId = null; + await UserGroupService.CreateAsync(_userGroup, Constants.Security.SuperUserKey); + } + + private async Task CreateUserAndGetStartNodePaths(params int[] startNodeIds) + { + var user = await CreateUser(startNodeIds); + + var mediaStartNodePaths = user.GetMediaStartNodePaths(EntityService, AppCaches.NoCache); + Assert.IsNotNull(mediaStartNodePaths); + + return mediaStartNodePaths; + } + + private async Task CreateUserAndGetStartNodeIds(params int[] startNodeIds) + { + var user = await CreateUser(startNodeIds); + + var mediaStartNodeIds = user.CalculateMediaStartNodeIds(EntityService, AppCaches.NoCache); + Assert.IsNotNull(mediaStartNodeIds); + + return mediaStartNodeIds; + } + + private async Task CreateUser(int[] startNodeIds) + { + var user = new UserBuilder() + .WithName(Guid.NewGuid().ToString("N")) + .WithStartMediaIds(startNodeIds) + .Build(); + UserService.Save(user); + + var attempt = await UserGroupService.AddUsersToUserGroupAsync( + new UsersToUserGroupManipulationModel(_userGroup.Key, [user.Key]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(attempt.Success); + return user; + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs new file mode 100644 index 0000000000..2272b60bbd --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs @@ -0,0 +1,336 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceTests +{ + [Test] + public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFirstPage_AsAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-1"].Id, _contentByName["1-10"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + // expected total is 2, because only two items under "1" are allowed (note the page size is 3 for good measure) + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // first and last content items are the ones allowed + Assert.AreEqual(_contentByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["1-10"].Key, children[1].Entity.Key); + + // explicitly verify the entity sort order, both so we know sorting works, + // and so we know it's actually the first and last item below "1" + Assert.AreEqual(0, children[0].Entity.SortOrder); + Assert.AreEqual(9, children[1].Entity.SortOrder); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInScope() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id, _contentByName["2-10"].Id); + Assert.AreEqual(2, contentStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["2"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(1, totalItems); + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + // only the "2-10" content item is returned, as "1-5" is out of scope + Assert.AreEqual(_contentByName["2-10"].Key, children[0].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id, _contentByName["2-10"].Id); + Assert.AreEqual(2, contentStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(0, totalItems); + Assert.AreEqual(0, children.Length); + } + + [Test] + public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginate() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths( + _contentByName["1-1"].Id, + _contentByName["1-3"].Id, + _contentByName["1-5"].Id, + _contentByName["1-7"].Id, + _contentByName["1-9"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1"].Key, + 0, + 2, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is 2 + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["1-3"].Key, children[1].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + + // next result page + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1"].Key, + 2, + 2, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is still 2 + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName["1-5"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["1-7"].Key, children[1].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + + // next result page + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1"].Key, + 4, + 2, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is still 2, but this is the last result page + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName["1-9"].Key, children[0].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchildren_AsAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3-3"].Key, + 0, + 100, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + Assert.AreEqual(5, children.Length); + Assert.Multiple(() => + { + // all children of "3-3" should be allowed because "3-3" is a start node + foreach (var childNumber in Enumerable.Range(1, 5)) + { + var child = children[childNumber - 1]; + Assert.AreEqual(_contentByName[$"3-3-{childNumber}"].Key, child.Entity.Key); + Assert.IsTrue(child.HasAccess); + } + }); + } + + [Test] + public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildren_AsAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3-3"].Id, _contentByName["3-3-4"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3-3"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3-3" - that is, the actual start nodes + Assert.AreEqual(_contentByName["3-3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["3-3-4"].Key, children[1].Entity.Key); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsChildren_AsNotAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3-3"].Id, _contentByName["3-5-3"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3" - that is, the parents of the actual start nodes + Assert.AreEqual(_contentByName["3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["3-5"].Key, children[1].Entity.Key); + + // both are disallowed - only the two children (the actual start nodes) are allowed + Assert.IsFalse(children[0].HasAccess); + Assert.IsFalse(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild() + { + // NOTE: this test proves that start node paths are *not* additive when structural inheritance is in play. + // if one has a start node that is a descendant to another start node, the descendant start node "wins" + // and the ancestor start node is ignored. this differs somewhat from the norm; we normally consider + // permissions additive (which in this case would mean ignoring the descendant rather than the ancestor). + + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id, _contentByName["3-3-1"].Id, _contentByName["3-3-5"].Id); + Assert.AreEqual(2, contentStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + Assert.AreEqual(1, totalItems); + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName["3-3"].Key, children[0].Entity.Key); + Assert.IsFalse(children[0].HasAccess); + }); + + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3-3"].Key, + 0, + 10, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3-3" - that is, the actual start nodes + Assert.AreEqual(_contentByName["3-3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["3-3-5"].Key, children[1].Entity.Key); + + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectResultOrder() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id, _contentByName["3-2"].Id, _contentByName["3-1"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + Assert.AreEqual(3, totalItems); + Assert.AreEqual(3, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName["3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["3-2"].Key, children[1].Entity.Key); + Assert.AreEqual(_contentByName["3-3"].Key, children[2].Entity.Key); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs new file mode 100644 index 0000000000..c73ac2778b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs @@ -0,0 +1,85 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceTests +{ + [Test] + public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_contentByName["1"].Id, _contentByName["5"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodeIds) + .ToArray(); + + // expected total is 2, because only two items at root ("1" amd "10") are allowed + Assert.AreEqual(2, roots.Length); + Assert.Multiple(() => + { + // first and last content items are the ones allowed + Assert.AreEqual(_contentByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_contentByName["5"].Key, roots[1].Entity.Key); + + // explicitly verify the entity sort order, both so we know sorting works, + // and so we know it's actually the first and last item at root + Assert.AreEqual(0, roots[0].Entity.SortOrder); + Assert.AreEqual(4, roots[1].Entity.SortOrder); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(roots[0].HasAccess); + Assert.IsTrue(roots[1].HasAccess); + }); + } + + [Test] + public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_AsNotAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_contentByName["1-3"].Id, _contentByName["3-3"].Id, _contentByName["5-3"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodeIds) + .ToArray(); + + Assert.AreEqual(3, roots.Length); + Assert.Multiple(() => + { + // the three start nodes are the children of the "1", "3" and "5" roots, respectively, so these are expected as roots + Assert.AreEqual(_contentByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_contentByName["3"].Key, roots[1].Entity.Key); + Assert.AreEqual(_contentByName["5"].Key, roots[2].Entity.Key); + + // all are disallowed - only the children (the actual start nodes) are allowed + Assert.IsTrue(roots.All(r => r.HasAccess is false)); + }); + } + + [Test] + public async Task RootUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildRoots_AsNotAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_contentByName["1-2-3"].Id, _contentByName["2-3-4"].Id, _contentByName["3-4-5"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodeIds) + .ToArray(); + + Assert.AreEqual(3, roots.Length); + Assert.Multiple(() => + { + // the three start nodes are the grandchildren of the "1", "2" and "3" roots, respectively, so these are expected as roots + Assert.AreEqual(_contentByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_contentByName["2"].Key, roots[1].Entity.Key); + Assert.AreEqual(_contentByName["3"].Key, roots[2].Entity.Key); + + // all are disallowed - only the grandchildren (the actual start nodes) are allowed + Assert.IsTrue(roots.All(r => r.HasAccess is false)); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs new file mode 100644 index 0000000000..5cc6bff35d --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +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.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] +public partial class UserStartNodeEntitiesServiceTests : UmbracoIntegrationTest +{ + private Dictionary _contentByName = new (); + private IUserGroup _userGroup; + + private IContentService ContentService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IUserGroupService UserGroupService => GetRequiredService(); + + private IUserService UserService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + private IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService(); + + protected readonly Ordering BySortOrder = Ordering.By("sortOrder"); + + protected override void ConfigureTestServices(IServiceCollection services) + { + base.ConfigureTestServices(services); + services.AddTransient(); + } + + [SetUp] + public async Task SetUpTestAsync() + { + if (_contentByName.Any()) + { + 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, 5)) + { + var root = new ContentBuilder() + .WithContentType(contentType) + .WithName($"{rootNumber}") + .Build(); + ContentService.Save(root); + _contentByName[root.Name!] = root; + + foreach (var childNumber in Enumerable.Range(1, 10)) + { + var child = new ContentBuilder() + .WithContentType(contentType) + .WithParent(root) + .WithName($"{rootNumber}-{childNumber}") + .Build(); + ContentService.Save(child); + _contentByName[child.Name!] = child; + + foreach (var grandChildNumber in Enumerable.Range(1, 5)) + { + var grandchild = new ContentBuilder() + .WithContentType(contentType) + .WithParent(child) + .WithName($"{rootNumber}-{childNumber}-{grandChildNumber}") + .Build(); + ContentService.Save(grandchild); + _contentByName[grandchild.Name!] = grandchild; + } + } + } + + _userGroup = new UserGroupBuilder() + .WithAlias("theGroup") + .WithAllowedSections(["content"]) + .Build(); + _userGroup.StartContentId = null; + await UserGroupService.CreateAsync(_userGroup, Constants.Security.SuperUserKey); + } + + private async Task CreateUserAndGetStartNodePaths(params int[] startNodeIds) + { + var user = await CreateUser(startNodeIds); + + var contentStartNodePaths = user.GetContentStartNodePaths(EntityService, AppCaches.NoCache); + Assert.IsNotNull(contentStartNodePaths); + + return contentStartNodePaths; + } + + private async Task CreateUserAndGetStartNodeIds(params int[] startNodeIds) + { + var user = await CreateUser(startNodeIds); + + var contentStartNodeIds = user.CalculateContentStartNodeIds(EntityService, AppCaches.NoCache); + Assert.IsNotNull(contentStartNodeIds); + + return contentStartNodeIds; + } + + private async Task CreateUser(int[] startNodeIds) + { + var user = new UserBuilder() + .WithName(Guid.NewGuid().ToString("N")) + .WithStartContentIds(startNodeIds) + .Build(); + UserService.Save(user); + + var attempt = await UserGroupService.AddUsersToUserGroupAsync( + new UsersToUserGroupManipulationModel(_userGroup.Key, [user.Key]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(attempt.Success); + return user; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ClearSchedule.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ClearSchedule.cs new file mode 100644 index 0000000000..d639190cfa --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ClearSchedule.cs @@ -0,0 +1,193 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_Clear_Schedule_Invariant() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishInvariantAsync(content); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var clearScheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = Constants.System.InvariantCulture, + Schedule = new ContentScheduleModel(), + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(clearScheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsNull(content!.PublishDate); + Assert.AreEqual(0, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Clear_Schedule_Single_Culture() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel(), + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(4, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Clear_Schedule_Some_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel(), + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel(), + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(2, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Clear_Schedule_All_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel(), + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel(), + }, + new() + { + Culture = setupInfo.LangBe.IsoCode, + Schedule = new ContentScheduleModel(), + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual(0, schedules.FullSchedule.Count); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.Publish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.Publish.cs new file mode 100644 index 0000000000..9e1669ae35 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.Publish.cs @@ -0,0 +1,280 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_Publish_Single_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var setupData = await CreateVariantContentAsync(langEn, langDa, langBe, contentType); + + var publishAttempt = await ContentPublishingService.PublishAsync( + setupData.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + var content = ContentService.GetById(setupData.Key); + Assert.AreEqual(1, content!.PublishedCultures.Count()); + } + + [Test] + public async Task Can_Publish_Some_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync(langEn, langDa, langBe, contentType); + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() { Culture = langEn.IsoCode }, new() { Culture = langDa.IsoCode }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + content = ContentService.GetById(content.Key); + Assert.AreEqual(2, content!.PublishedCultures.Count()); + } + + [Test] + public async Task Can_Publish_All_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync(langEn, langDa, langBe, contentType); + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + new() { Culture = langBe.IsoCode }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + content = ContentService.GetById(content.Key); + Assert.AreEqual(3, content!.PublishedCultures.Count()); + } + + [Test] + public async Task Cannot_Publish_Invariant_In_Variant_Setup() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant, publishAttempt.Status); + + content = ContentService.GetById(content.Key); + Assert.AreEqual(0, content!.PublishedCultures.Count()); + } + + [Test] + public async Task Can_Publish_Invariant_In_Invariant_Setup() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + + content = ContentService.GetById(content.Key); + Assert.NotNull(content!.PublishDate); + } + + [Test] + public async Task Cannot_Publish_Unknown_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + new() { Culture = UnknownCulture }, + ], + Constants.Security.SuperUserKey); + + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, publishAttempt.Status); + + content = ContentService.GetById(content.Key); + Assert.AreEqual(0, content!.PublishedCultures.Count()); + } + + [Test] + public async Task Cannot_Publish_Scheduled_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + } + ], + Constants.Security.SuperUserKey); + + if (scheduleAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.CultureAwaitingRelease, publishAttempt.Status); + + content = ContentService.GetById(content.Key); + Assert.AreEqual(0, content!.PublishedCultures.Count()); + } + + // TODO: The following three tests verify existing functionality that could be reconsidered. + // The existing behaviour, verified on Umbraco 13 and 15 is as follows: + // - For invariant content, if a parent is unpublished and I try to publish the child, I get a ContentPublishingOperationStatus.PathNotPublished error. + // - For variant content, if I publish the parent in English but not Danish, I can publish the child in Danish. + // This is inconsistent so we should consider if this is the desired behaviour. + // For now though, the following tests verify the existing behaviour. + + [Test] + public async Task Cannot_Publish_With_Unpublished_Parent() + { + var doctype = await SetupInvariantDoctypeAsync(); + var parentContent = await CreateInvariantContentAsync(doctype); + var childContent = await CreateInvariantContentAsync(doctype, parentContent.Key); + + var publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.PathNotPublished, publishAttempt.Status); + + // Now publish the parent and re-try publishing the child. + publishAttempt = await ContentPublishingService.PublishAsync( + parentContent.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + + publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + } + + [Test] + public async Task Cannot_Publish_Culture_With_Unpublished_Parent() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var parentContent = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + var childContent = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType, + parentContent.Key); + + // Publish child in English, should not succeed. + var publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.PathNotPublished, publishAttempt.Status); + + // Now publish the parent and re-try publishing the child. + publishAttempt = await ContentPublishingService.PublishAsync( + parentContent.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + + publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + } + + [Test] + public async Task Can_Publish_Culture_With_Unpublished_Parent_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var parentContent = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + var childContent = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType, + parentContent.Key); + + // Publish parent in English. + var publishAttempt = await ContentPublishingService.PublishAsync( + parentContent.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + + // Publish child in English, should succeed. + publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + + // Publish child in Danish, should also succeed. + publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = langDa.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.SchedulePublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.SchedulePublish.cs new file mode 100644 index 0000000000..e3e0a65f2a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.SchedulePublish.cs @@ -0,0 +1,258 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_Schedule_Publish_Invariant() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = Constants.System.InvariantCulture, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsNull(content!.PublishDate); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Publish_Single_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Publish_Some_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langDa.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual(2, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Publish_All_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langBe.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langDa.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langBe.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual(3, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Cannot_Schedule_Publish_Unknown_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = UnknownCulture, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsFalse(scheduleAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual(0, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Publish_And_Schedule_Different_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(1, content!.PublishedCultures.Count()); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ScheduleUnpublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ScheduleUnpublish.cs new file mode 100644 index 0000000000..c49f8684ab --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ScheduleUnpublish.cs @@ -0,0 +1,221 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_Schedule_Unpublish_Invariant() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = Constants.System.InvariantCulture, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsNull(content!.PublishDate); + Assert.AreEqual( + _scheduleUnPublishDate, + schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Unpublish_Single_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Unpublish_Some_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langDa.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual(2, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Unpublish_All_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langBe.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langDa.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langBe.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual(3, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Cannot_Schedule_Unpublish_Unknown_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + new() + { + Culture = UnknownCulture, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate } + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsFalse(scheduleAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual(0, schedules.FullSchedule.Count); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnschedulePublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnschedulePublish.cs new file mode 100644 index 0000000000..c4d0ab1c1e --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnschedulePublish.cs @@ -0,0 +1,248 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_UnSchedule_Publish_Invariant() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishInvariantAsync(content); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var unscheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = Constants.System.InvariantCulture, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(unscheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsNull(content!.PublishDate); + Assert.IsFalse(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Any()); + Assert.IsTrue(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Publish_Single_Culture() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); + Assert.AreEqual(5, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Publish_Some_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate } + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate } + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); + Assert.AreEqual(4, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Publish_All_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + new() + { + Culture = setupInfo.LangBe.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Release).Any()); + Assert.AreEqual(3, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Cannot_Unschedule_Publish_Unknown_Culture() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + new() + { + Culture = UnknownCulture, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsFalse(scheduleAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual(6, schedules.FullSchedule.Count); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnscheduleUnpublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnscheduleUnpublish.cs new file mode 100644 index 0000000000..3e3a5ea61a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnscheduleUnpublish.cs @@ -0,0 +1,247 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_UnSchedule_Unpublish_Invariant() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishInvariantAsync(content); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var unscheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = Constants.System.InvariantCulture, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(unscheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsNull(content!.PublishDate); + Assert.IsFalse(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Any()); + Assert.IsTrue(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Any()); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Unpublish_Single_Culture() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(5, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Unpublish_Some_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(4, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Unpublish_All_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = setupInfo.LangBe.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(3, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Cannot_Unschedule_Unpublish_Unknown_Culture() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = UnknownCulture, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsFalse(scheduleAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual(6, schedules.FullSchedule.Count); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.cs index 950fdd925b..d1d283612c 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.cs @@ -18,7 +18,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; Database = UmbracoTestOptions.Database.NewSchemaPerTest, PublishedRepositoryEvents = true, WithApplication = true)] -internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent { private const string UnknownCulture = "ke-Ke"; @@ -39,1345 +39,7 @@ internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWith private IContentEditingService ContentEditingService => GetRequiredService(); - #region Publish - - [Test] - public async Task Can_Publish_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List { new() { Culture = setupInfo.LangEn.IsoCode } }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(publishAttempt.Success); - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(1, content!.PublishedCultures.Count()); - } - - [Test] - public async Task Can_Publish_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() { Culture = setupInfo.LangEn.IsoCode }, new() { Culture = setupInfo.LangDa.IsoCode }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(publishAttempt.Success); - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(2, content!.PublishedCultures.Count()); - } - - [Test] - public async Task Can_Publish_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() { Culture = setupInfo.LangEn.IsoCode }, - new() { Culture = setupInfo.LangDa.IsoCode }, - new() { Culture = setupInfo.LangBe.IsoCode }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(publishAttempt.Success); - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(3, content!.PublishedCultures.Count()); - } - - [Test] - public async Task Can_NOT_Publish_Invariant_In_Variant_Setup() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List { new() { Culture = Constants.System.InvariantCulture } }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(publishAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant, publishAttempt.Status); - - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(0, content!.PublishedCultures.Count()); - } - - [Test] - public async Task Can_Publish_Invariant_In_Invariant_Setup() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List { new() { Culture = Constants.System.InvariantCulture } }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(publishAttempt.Success); - - var content = ContentService.GetById(setupData.Key); - Assert.NotNull(content!.PublishDate); - } - //todo more tests for invariant - //todo update schedule date - - [Test] - public async Task Can_NOT_Publish_Unknown_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() { Culture = setupInfo.LangEn.IsoCode }, - new() { Culture = setupInfo.LangDa.IsoCode }, - new() { Culture = UnknownCulture }, - }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(publishAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, publishAttempt.Status); - - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(0, content!.PublishedCultures.Count()); - } - - [Test] - public async Task Can_NOT_Publish_Scheduled_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - } - }, - Constants.Security.SuperUserKey); - - if (scheduleAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List { new() { Culture = setupInfo.LangEn.IsoCode } }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(publishAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.CultureAwaitingRelease, publishAttempt.Status); - - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(0, content!.PublishedCultures.Count()); - } - - #endregion - - #region Schedule Publish - - [Test] - public async Task Can_Schedule_Publish_Invariant() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = Constants.System.InvariantCulture, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsNull(content!.PublishDate); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Publish_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Publish_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual(2, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Publish_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangBe.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual(3, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_NOT_Schedule_Publish_Unknown_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = UnknownCulture, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(scheduleAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual(0, schedules.FullSchedule.Count); - }); - } - - #endregion - - #region Schedule Unpublish - - [Test] - public async Task Can_Schedule_Unpublish_Invariant() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = Constants.System.InvariantCulture, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsNull(content!.PublishDate); - Assert.AreEqual( - _scheduleUnPublishDate, - schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Unpublish_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Unpublish_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual(2, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Unpublish_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangBe.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual(3, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_NOT_Schedule_Unpublish_Unknown_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - new() - { - Culture = UnknownCulture, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate } - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(scheduleAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual(0, schedules.FullSchedule.Count); - }); - } - - #endregion - - #region Unschedule Publish - - [Test] - public async Task Can_UnSchedule_Publish_Invariant() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishInvariantAsync(setupData); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var unscheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = Constants.System.InvariantCulture, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(unscheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsNull(content!.PublishDate); - Assert.IsFalse(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Any()); - Assert.IsTrue(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Publish_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); - Assert.AreEqual(5, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Publish_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate } - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate } - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); - Assert.AreEqual(4, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Publish_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - new() - { - Culture = setupInfo.LangBe.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Release).Any()); - Assert.AreEqual(3, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_NOT_Unschedule_Publish_Unknown_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - new() - { - Culture = UnknownCulture, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(scheduleAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual(6, schedules.FullSchedule.Count); - }); - } - - #endregion - - #region Unschedule Unpublish - - [Test] - public async Task Can_UnSchedule_Unpublish_Invariant() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishInvariantAsync(setupData); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var unscheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = Constants.System.InvariantCulture, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(unscheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsNull(content!.PublishDate); - Assert.IsFalse(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Any()); - Assert.IsTrue(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Any()); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Unpublish_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(5, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Unpublish_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(4, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Unpublish_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangBe.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(3, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_NOT_Unschedule_Unpublish_Unknown_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = UnknownCulture, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(scheduleAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual(6, schedules.FullSchedule.Count); - }); - } - - #endregion - - #region Clean Schedule - - [Test] - public async Task Can_Clear_Schedule_Invariant() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishInvariantAsync(setupData); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var clearScheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = Constants.System.InvariantCulture, - Schedule = new ContentScheduleModel(), - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(clearScheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsNull(content!.PublishDate); - Assert.AreEqual(0, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Clear_Schedule_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel(), - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(4, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Clear_Schedule_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel(), - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel(), - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(2, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Clear_Schedule_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel(), - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel(), - }, - new() - { - Culture = setupInfo.LangBe.IsoCode, - Schedule = new ContentScheduleModel(), - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual(0, schedules.FullSchedule.Count); - }); - } - - #endregion - - #region Combinations - - [Test] - public async Task Can_Publish_And_Schedule_Different_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(1, content!.PublishedCultures.Count()); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - #endregion - - #region Helper methods - - private async Task<(ILanguage LangEn, ILanguage LangDa, ILanguage LangBe, IContentType contentType)> - SetupVariantDoctypeAsync() + private async Task<(ILanguage LangEn, ILanguage LangDa, ILanguage LangBe, IContentType contentType)> SetupVariantDoctypeAsync() { var langEn = (await LanguageService.GetAsync("en-US"))!; var langDa = new LanguageBuilder() @@ -1397,10 +59,10 @@ internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWith .WithName("Variant Content") .WithContentVariation(ContentVariation.Culture) .AddPropertyGroup() - .WithAlias("content") - .WithName("Content") - .WithSupportsPublishing(true) - .Done() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .Done() .Build(); contentType.AllowedAsRoot = true; @@ -1410,11 +72,17 @@ internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWith throw new Exception("Something unexpected went wrong setting up the test data structure"); } + contentType.AllowedContentTypes = [new ContentTypeSort(contentType.Key, 1, contentType.Alias)]; + var updateAttempt = await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + if (updateAttempt.Success is false) + { + throw new Exception("Something unexpected went wrong setting up the test data structure"); + } + return (langEn, langDa, langBe, contentType); } - private async Task CreateVariantContentAsync(ILanguage langEn, ILanguage langDa, ILanguage langBe, - IContentType contentType) + private async Task CreateVariantContentAsync(ILanguage langEn, ILanguage langDa, ILanguage langBe, IContentType contentType, Guid? parentKey = null) { var documentKey = Guid.NewGuid(); @@ -1422,8 +90,9 @@ internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWith { Key = documentKey, ContentTypeKey = contentType.Key, - Variants = new[] - { + ParentKey = parentKey, + Variants = + [ new VariantModel { Name = langEn.CultureName, @@ -1442,7 +111,7 @@ internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWith Culture = langBe.IsoCode, Properties = Enumerable.Empty(), } - } + ] }; var createAttempt = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); @@ -1462,36 +131,46 @@ internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWith var contentType = new ContentTypeBuilder() .WithAlias("invariantContent") .WithName("Invariant Content") + .WithAllowAsRoot(true) .AddPropertyGroup() - .WithAlias("content") - .WithName("Content") - .WithSupportsPublishing(true) - .Done() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .Done() .Build(); - contentType.AllowedAsRoot = true; var createAttempt = await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); if (createAttempt.Success is false) { throw new Exception("Something unexpected went wrong setting up the test data structure"); } + contentType.AllowedContentTypes = [new ContentTypeSort(contentType.Key, 1, contentType.Alias)]; + var updateAttempt = await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + if (updateAttempt.Success is false) + { + throw new Exception("Something unexpected went wrong setting up the test data structure"); + } + return contentType; } - private async Task CreateInvariantContentAsync(IContentType contentType) + private async Task CreateInvariantContentAsync(IContentType contentType, Guid? parentKey = null) { var documentKey = Guid.NewGuid(); var createModel = new ContentCreateModel { - Key = documentKey, ContentTypeKey = contentType.Key, InvariantName = "Test", + Key = documentKey, + ContentTypeKey = contentType.Key, + InvariantName = "Test", + ParentKey = parentKey, }; var createAttempt = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); if (createAttempt.Success is false) { - throw new Exception("Something unexpected went wrong setting up the test data"); + throw new Exception($"Something unexpected went wrong setting up the test data. Status: {createAttempt.Status}"); } return createAttempt.Result.Content!; @@ -1503,8 +182,7 @@ internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWith (ILanguage LangEn, ILanguage LangDa, ILanguage LangBe, IContentType contentType) setupInfo) => await ContentPublishingService.PublishAsync( setupData.Key, - new List - { + [ new() { Culture = setupInfo.LangEn.IsoCode, @@ -1531,7 +209,7 @@ internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWith PublishDate = _schedulePublishDate, UnpublishDate = _scheduleUnPublishDate, }, }, - }, + ], Constants.Security.SuperUserKey); private async Task> @@ -1539,8 +217,7 @@ internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWith IContent setupData) => await ContentPublishingService.PublishAsync( setupData.Key, - new List - { + [ new() { Culture = Constants.System.InvariantCulture, @@ -1550,8 +227,6 @@ internal sealed class ContentPublishingServiceTests : UmbracoIntegrationTestWith PublishDate = _schedulePublishDate, UnpublishDate = _scheduleUnPublishDate, }, }, - }, + ], Constants.Security.SuperUserKey); - - #endregion } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs index 89a669dd33..1dcb33de0c 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs @@ -9,8 +9,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; internal sealed partial class DocumentNavigationServiceTests { - [Test] - public async Task Structure_Can_Rebuild() + [TestCase(1, TestName = "Structure_Can_Rebuild")] + [TestCase(2, TestName = "Structure_Can_Rebuild_MultipleTimes")] + public async Task Structure_Can_Rebuild(int numberOfRebuilds) { // Arrange Guid nodeKey = Root.Key; @@ -21,6 +22,7 @@ internal sealed partial class DocumentNavigationServiceTests DocumentNavigationQueryService.TryGetDescendantsKeys(nodeKey, out IEnumerable originalDescendantsKeys); DocumentNavigationQueryService.TryGetAncestorsKeys(nodeKey, out IEnumerable originalAncestorsKeys); DocumentNavigationQueryService.TryGetSiblingsKeys(nodeKey, out IEnumerable originalSiblingsKeys); + DocumentNavigationQueryService.TryGetRootKeys(out IEnumerable originalRouteKeys); // In-memory navigation structure is empty here var newDocumentNavigationService = new DocumentNavigationService( @@ -30,7 +32,10 @@ internal sealed partial class DocumentNavigationServiceTests var initialNodeExists = newDocumentNavigationService.TryGetParentKey(nodeKey, out _); // Act - await newDocumentNavigationService.RebuildAsync(); + for (int i = 0; i < numberOfRebuilds; i++) + { + await newDocumentNavigationService.RebuildAsync(); + } // Capture rebuilt state var nodeExists = newDocumentNavigationService.TryGetParentKey(nodeKey, out Guid? parentKeyFromRebuild); @@ -38,6 +43,7 @@ internal sealed partial class DocumentNavigationServiceTests newDocumentNavigationService.TryGetDescendantsKeys(nodeKey, out IEnumerable descendantsKeysFromRebuild); newDocumentNavigationService.TryGetAncestorsKeys(nodeKey, out IEnumerable ancestorsKeysFromRebuild); newDocumentNavigationService.TryGetSiblingsKeys(nodeKey, out IEnumerable siblingsKeysFromRebuild); + newDocumentNavigationService.TryGetRootKeys(out IEnumerable routeKeysFromRebuild); // Assert Assert.Multiple(() => @@ -53,11 +59,13 @@ internal sealed partial class DocumentNavigationServiceTests CollectionAssert.AreEquivalent(originalDescendantsKeys, descendantsKeysFromRebuild); CollectionAssert.AreEquivalent(originalAncestorsKeys, ancestorsKeysFromRebuild); CollectionAssert.AreEquivalent(originalSiblingsKeys, siblingsKeysFromRebuild); + CollectionAssert.AreEquivalent(originalRouteKeys, routeKeysFromRebuild); }); } - [Test] - public async Task Bin_Structure_Can_Rebuild() + [TestCase(1, TestName = "Bin_Structure_Can_Rebuild")] + [TestCase(2, TestName = "Bin_Structure_Can_Rebuild_MultipleTimes")] + public async Task Bin_Structure_Can_Rebuild(int numberOfRebuilds) { // Arrange Guid nodeKey = Root.Key; @@ -69,6 +77,7 @@ internal sealed partial class DocumentNavigationServiceTests DocumentNavigationQueryService.TryGetDescendantsKeysInBin(nodeKey, out IEnumerable originalDescendantsKeys); DocumentNavigationQueryService.TryGetAncestorsKeysInBin(nodeKey, out IEnumerable originalAncestorsKeys); DocumentNavigationQueryService.TryGetSiblingsKeysInBin(nodeKey, out IEnumerable originalSiblingsKeys); + DocumentNavigationQueryService.TryGetRootKeys(out IEnumerable originalRouteKeys); // In-memory navigation structure is empty here var newDocumentNavigationService = new DocumentNavigationService( @@ -78,7 +87,10 @@ internal sealed partial class DocumentNavigationServiceTests var initialNodeExists = newDocumentNavigationService.TryGetParentKeyInBin(nodeKey, out _); // Act - await newDocumentNavigationService.RebuildBinAsync(); + for (int i = 0; i < numberOfRebuilds; i++) + { + await newDocumentNavigationService.RebuildBinAsync(); + } // Capture rebuilt state var nodeExists = newDocumentNavigationService.TryGetParentKeyInBin(nodeKey, out Guid? parentKeyFromRebuild); @@ -86,6 +98,7 @@ internal sealed partial class DocumentNavigationServiceTests newDocumentNavigationService.TryGetDescendantsKeysInBin(nodeKey, out IEnumerable descendantsKeysFromRebuild); newDocumentNavigationService.TryGetAncestorsKeysInBin(nodeKey, out IEnumerable ancestorsKeysFromRebuild); newDocumentNavigationService.TryGetSiblingsKeysInBin(nodeKey, out IEnumerable siblingsKeysFromRebuild); + newDocumentNavigationService.TryGetRootKeys(out IEnumerable routeKeysFromRebuild); // Assert Assert.Multiple(() => @@ -101,6 +114,7 @@ internal sealed partial class DocumentNavigationServiceTests CollectionAssert.AreEquivalent(originalDescendantsKeys, descendantsKeysFromRebuild); CollectionAssert.AreEquivalent(originalAncestorsKeys, ancestorsKeysFromRebuild); CollectionAssert.AreEquivalent(originalSiblingsKeys, siblingsKeysFromRebuild); + CollectionAssert.AreEquivalent(originalRouteKeys, routeKeysFromRebuild); }); } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TemporaryFileServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TemporaryFileServiceTests.cs new file mode 100644 index 0000000000..d7f719048e --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TemporaryFileServiceTests.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Attributes; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] +public class TemporaryFileServiceTests : UmbracoIntegrationTest +{ + private ITemporaryFileService TemporaryFileService => GetRequiredService(); + + public static void ConfigureAllowedUploadedFileExtensions(IUmbracoBuilder builder) + { + builder.Services.Configure(config => + config.AllowedUploadedFileExtensions = new HashSet { "txt" }); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureAllowedUploadedFileExtensions))] + public async Task Can_Create_Get_And_Delete_Temporary_File() + { + var key = Guid.NewGuid(); + const string FileName = "test.txt"; + const string FileContents = "test"; + var model = new CreateTemporaryFileModel + { + FileName = FileName, + Key = key, + OpenReadStream = () => + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write(FileContents); + writer.Flush(); + stream.Position = 0; + return stream; + } + }; + var createAttempt = await TemporaryFileService.CreateAsync(model); + Assert.IsTrue(createAttempt.Success); + + TemporaryFileModel? fileModel = await TemporaryFileService.GetAsync(key); + Assert.IsNotNull(fileModel); + Assert.AreEqual(key, fileModel.Key); + Assert.AreEqual(FileName, fileModel.FileName); + + using (var reader = new StreamReader(fileModel.OpenReadStream())) + { + string fileContents = reader.ReadToEnd(); + Assert.AreEqual(FileContents, fileContents); + } + + var deleteAttempt = await TemporaryFileService.DeleteAsync(key); + Assert.IsTrue(createAttempt.Success); + + fileModel = await TemporaryFileService.GetAsync(key); + Assert.IsNull(fileModel); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureAllowedUploadedFileExtensions))] + public async Task Cannot_Create_File_Outside_Of_Temporary_Files_Root() + { + var key = Guid.NewGuid(); + const string FileName = "../test.txt"; + var model = new CreateTemporaryFileModel + { + FileName = FileName, + Key = key, + OpenReadStream = () => + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write(string.Empty); + writer.Flush(); + stream.Position = 0; + return stream; + } + }; + var createAttempt = await TemporaryFileService.CreateAsync(model); + Assert.IsFalse(createAttempt.Success); + Assert.AreEqual(TemporaryFileOperationStatus.InvalidFileName, createAttempt.Status); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/LocksTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/LocksTests.cs index d4409157df..f3e19eea94 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/LocksTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/LocksTests.cs @@ -527,7 +527,7 @@ internal sealed class LocksTests : UmbracoIntegrationTest } } - [Retry(3)] // TODO make this test non-flaky. + [NUnit.Framework.Ignore("This test is very flaky, and is stopping our nightlys")] [Test] public void Read_Lock_Waits_For_Write_Lock() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs index 93395a768e..dd4a2452e4 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs @@ -1,8 +1,7 @@ -using Microsoft.Extensions.DependencyInjection; +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.Core.Services.OperationStatus; using Umbraco.Cms.Tests.Integration.Attributes; @@ -12,29 +11,20 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; public partial class ContentEditingServiceTests { protected IRelationService RelationService => GetRequiredService(); + public static void ConfigureDisableDeleteWhenReferenced(IUmbracoBuilder builder) - { - builder.Services.Configure(config => + => builder.Services.Configure(config => config.DisableDeleteWhenReferenced = true); - } - - public void Relate(IContent child, IContent parent) - { - var relatedContentRelType = RelationService.GetRelationTypeByAlias(Constants.Conventions.RelationTypes.RelatedDocumentAlias); - - var relation = RelationService.Relate(child.Id, parent.Id, relatedContentRelType); - RelationService.Save(relation); - } - [Test] [ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))] - public async Task Cannot_Delete_Referenced_Content() + public async Task Cannot_Delete_When_Content_Is_Related_As_A_Child_And_Configured_To_Disable_When_Related() { var moveAttempt = await ContentEditingService.MoveToRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); Assert.IsTrue(moveAttempt.Success); - Relate(Subpage, Subpage2); + // Setup a relation where the page being deleted is related to another page as a child (e.g. the other page has a picker and has selected this page). + Relate(Subpage2, Subpage); var result = await ContentEditingService.DeleteFromRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.CannotDeleteWhenReferenced, result.Status); @@ -44,6 +34,24 @@ public partial class ContentEditingServiceTests Assert.IsNotNull(subpage); } + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))] + public async Task Can_Delete_When_Content_Is_Related_As_A_Parent_And_Configured_To_Disable_When_Related() + { + var moveAttempt = await ContentEditingService.MoveToRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveAttempt.Success); + + // Setup a relation where the page being deleted is related to another page as a child (e.g. the other page has a picker and has selected this page). + Relate(Subpage, Subpage2); + var result = await ContentEditingService.DeleteFromRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + // re-get and verify deleted + var subpage = await ContentEditingService.GetAsync(Subpage.Key); + Assert.IsNull(subpage); + } + [TestCase(true)] [TestCase(false)] public async Task Can_Delete_FromRecycleBin(bool variant) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs index ac7700131f..35a5b3244f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -9,12 +9,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; public partial class ContentEditingServiceTests { - - public static void ConfigureDisableDelete(IUmbracoBuilder builder) - { - builder.Services.Configure(config => + public static new void ConfigureDisableUnpublishWhenReferencedTrue(IUmbracoBuilder builder) + => builder.Services.Configure(config => config.DisableUnpublishWhenReferenced = true); - } [TestCase(true)] [TestCase(false)] @@ -33,10 +30,11 @@ public partial class ContentEditingServiceTests } [Test] - [ConfigureBuilder(ActionName = nameof(ConfigureDisableDelete))] - public async Task Cannot_Move_To_Recycle_Bin_If_Referenced() + [ConfigureBuilder(ActionName = nameof(ConfigureDisableUnpublishWhenReferencedTrue))] + public async Task Cannot_Move_To_Recycle_Bin_When_Content_Is_Related_As_A_Child_And_Configured_To_Disable_When_Related() { - Relate(Subpage, Subpage2); + // Setup a relation where the page being deleted is related to another page as a child (e.g. the other page has a picker and has selected this page). + Relate(Subpage2, Subpage); var moveAttempt = await ContentEditingService.MoveToRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); Assert.IsFalse(moveAttempt.Success); Assert.AreEqual(ContentEditingOperationStatus.CannotMoveToRecycleBinWhenReferenced, moveAttempt.Status); @@ -47,6 +45,22 @@ public partial class ContentEditingServiceTests Assert.IsFalse(content.Trashed); } + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableUnpublishWhenReferencedTrue))] + public async Task Can_Move_To_Recycle_Bin_When_Content_Is_Related_As_A_Parent_And_Configured_To_Disable_When_Related() + { + // Setup a relation where the page being deleted is related to another page as a child (e.g. the other page has a picker and has selected this page). + Relate(Subpage, Subpage2); + var moveAttempt = await ContentEditingService.MoveToRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveAttempt.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, moveAttempt.Status); + + // re-get and verify moved + var content = await ContentEditingService.GetAsync(Subpage.Key); + Assert.IsNotNull(content); + Assert.IsTrue(content.Trashed); + } + [Test] public async Task Cannot_Move_Non_Existing_To_Recycle_Bin() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs index 5e4e74b793..954012c74a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs @@ -1,4 +1,4 @@ -using NUnit.Framework; +using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; @@ -346,6 +346,7 @@ public partial class ContentEditingServiceTests InvariantName = "Updated Name", InvariantProperties = new[] { + new PropertyValueModel { Alias = "title", Value = "The initial title" }, new PropertyValueModel { Alias = "label", Value = "The updated label value" } } }; @@ -390,6 +391,7 @@ public partial class ContentEditingServiceTests Name = "Updated English Name", Properties = new [] { + new PropertyValueModel { Alias = "variantTitle", Value = "The initial English title" }, new PropertyValueModel { Alias = "variantLabel", Value = "The updated English label value" } } }, @@ -399,6 +401,7 @@ public partial class ContentEditingServiceTests Name = "Updated Danish Name", Properties = new [] { + new PropertyValueModel { Alias = "variantTitle", Value = "The initial Danish title" }, new PropertyValueModel { Alias = "variantLabel", Value = "The updated Danish label value" } } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Validate.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Validate.cs new file mode 100644 index 0000000000..1589668a78 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Validate.cs @@ -0,0 +1,191 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ContentEditingServiceTests +{ + [Test] + public async Task Can_Validate_Valid_Invariant_Content() + { + var content = await CreateInvariantContent(); + + var validateContentUpdateModel = new ValidateContentUpdateModel + { + InvariantName = "Updated Name", + InvariantProperties = + [ + new PropertyValueModel { Alias = "title", Value = "The updated title" }, + new PropertyValueModel { Alias = "text", Value = "The updated text" } + ] + }; + + Attempt result = await ContentEditingService.ValidateUpdateAsync(content.Key, validateContentUpdateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + } + + [Test] + public async Task Will_Fail_Invalid_Invariant_Content() + { + var content = await CreateInvariantContent(); + + var validateContentUpdateModel = new ValidateContentUpdateModel + { + InvariantName = "Updated Name", + InvariantProperties = + [ + new PropertyValueModel { Alias = "title", Value = null }, + new PropertyValueModel { Alias = "text", Value = "The updated text" } + ] + }; + + Attempt result = await ContentEditingService.ValidateUpdateAsync(content.Key, validateContentUpdateModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.PropertyValidationError, result.Status); + Assert.AreEqual(1, result.Result.ValidationErrors.Count()); + Assert.AreEqual("#validation_invalidNull", result.Result.ValidationErrors.Single(x => x.Alias == "title").ErrorMessages[0]); + } + + [Test] + public async Task Can_Validate_Valid_Variant_Content() + { + var content = await CreateVariantContent(); + + var validateContentUpdateModel = new ValidateContentUpdateModel + { + InvariantProperties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" } + ], + Variants = + [ + new VariantModel + { + Culture = "en-US", + Name = "Updated English Name", + Properties = + [ + new PropertyValueModel { Alias = "variantTitle", Value = "The updated English title" } + ] + }, + new VariantModel + { + Culture = "da-DK", + Name = "Updated Danish Name", + Properties = + [ + new PropertyValueModel { Alias = "variantTitle", Value = "The updated Danish title" } + ] + } + ], + }; + + Attempt result = await ContentEditingService.ValidateUpdateAsync(content.Key, validateContentUpdateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + } + + [Test] + public async Task Will_Fail_Invalid_Variant_Content() + { + var content = await CreateVariantContent(); + + var validateContentUpdateModel = new ValidateContentUpdateModel + { + InvariantProperties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" } + ], + Variants = + [ + new VariantModel + { + Culture = "en-US", + Name = "Updated English Name", + Properties = + [ + new PropertyValueModel { Alias = "variantTitle", Value = "The updated English title" } + ] + }, + new VariantModel + { + Culture = "da-DK", + Name = "Updated Danish Name", + Properties = + [ + new PropertyValueModel { Alias = "variantTitle", Value = null } + ] + } + ], + }; + + Attempt result = await ContentEditingService.ValidateUpdateAsync(content.Key, validateContentUpdateModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.PropertyValidationError, result.Status); + Assert.AreEqual(1, result.Result.ValidationErrors.Count()); + Assert.AreEqual("#validation_invalidNull", result.Result.ValidationErrors.Single(x => x.Alias == "variantTitle" && x.Culture == "da-DK").ErrorMessages[0]); + } + + [Test] + public async Task Will_Succeed_For_Invalid_Variant_Content_Without_Access_To_Edited_Culture() + { + var content = await CreateVariantContent(); + + IUser englishEditor = await CreateEnglishLanguageOnlyEditor(); + + var validateContentUpdateModel = new ValidateContentUpdateModel + { + InvariantProperties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" } + ], + Variants = + [ + new VariantModel + { + Culture = "en-US", + Name = "Updated English Name", + Properties = + [ + new PropertyValueModel { Alias = "variantTitle", Value = "The updated English title" } + ] + } + ], + }; + + Attempt result = await ContentEditingService.ValidateUpdateAsync(content.Key, validateContentUpdateModel, englishEditor.Key); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + } + + private async Task CreateEnglishLanguageOnlyEditor() + { + var enUSLanguage = await LanguageService.GetAsync("en-US"); + var userGroup = new UserGroupBuilder() + .WithName("English Editors") + .WithAlias("englishEditors") + .WithAllowedLanguages([enUSLanguage.Id]) + .Build(); + + var createUserGroupResult = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserKey); + Assert.IsTrue(createUserGroupResult.Success); + + var createUserAttempt = await UserService.CreateAsync(Constants.Security.SuperUserKey, new UserCreateModel + { + Email = "english-editor@test.com", + Name = "Test English Editor", + UserName = "english-editor@test.com", + UserGroupKeys = new[] { userGroup.Key }.ToHashSet(), + }); + Assert.IsTrue(createUserAttempt.Success); + + return await UserService.GetAsync(createUserAttempt.Result.CreatedUser.Key); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs index 68daf63c8d..67daf08ae5 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs @@ -16,6 +16,14 @@ public partial class ContentEditingServiceTests : ContentEditingServiceTestsBase [SetUp] public void Setup() => ContentRepositoryBase.ThrowOnWarning = true; + public void Relate(IContent parent, IContent child) + { + var relatedContentRelType = RelationService.GetRelationTypeByAlias(Constants.Conventions.RelationTypes.RelatedDocumentAlias); + + var relation = RelationService.Relate(parent.Id, child.Id, relatedContentRelType); + RelationService.Save(relation); + } + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddNotificationHandler(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs index f9dd811f0f..ebad7f9545 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs @@ -21,7 +21,11 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit protected IContentBlueprintEditingService ContentBlueprintEditingService => GetRequiredService(); - private ILanguageService LanguageService => GetRequiredService(); + protected ILanguageService LanguageService => GetRequiredService(); + + protected IUserService UserService => GetRequiredService(); + + protected IUserGroupService UserGroupService => GetRequiredService(); protected IContentType CreateInvariantContentType(params ITemplate[] templates) { @@ -30,22 +34,23 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit .WithName("Invariant Test") .WithContentVariation(ContentVariation.Nothing) .AddPropertyType() - .WithAlias("title") - .WithName("Title") - .WithVariations(ContentVariation.Nothing) - .Done() + .WithAlias("title") + .WithName("Title") + .WithMandatory(true) + .WithVariations(ContentVariation.Nothing) + .Done() .AddPropertyType() - .WithAlias("text") - .WithName("Text") - .WithVariations(ContentVariation.Nothing) - .Done() + .WithAlias("text") + .WithName("Text") + .WithVariations(ContentVariation.Nothing) + .Done() .AddPropertyType() - .WithAlias("label") - .WithName("Label") - .WithDataTypeId(Constants.DataTypes.LabelString) - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) - .WithVariations(ContentVariation.Nothing) - .Done(); + .WithAlias("label") + .WithName("Label") + .WithDataTypeId(Constants.DataTypes.LabelString) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) + .WithVariations(ContentVariation.Nothing) + .Done(); foreach (var template in templates) { @@ -81,22 +86,23 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit .WithName("Culture Variation Test") .WithContentVariation(ContentVariation.Culture) .AddPropertyType() - .WithAlias("variantTitle") - .WithName("Variant Title") - .WithVariations(ContentVariation.Culture) - .Done() + .WithAlias("variantTitle") + .WithName("Variant Title") + .WithMandatory(true) + .WithVariations(ContentVariation.Culture) + .Done() .AddPropertyType() - .WithAlias("invariantTitle") - .WithName("Invariant Title") - .WithVariations(ContentVariation.Nothing) - .Done() + .WithAlias("invariantTitle") + .WithName("Invariant Title") + .WithVariations(ContentVariation.Nothing) + .Done() .AddPropertyType() - .WithAlias("variantLabel") - .WithName("Variant Label") - .WithDataTypeId(Constants.DataTypes.LabelString) - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) - .WithVariations(ContentVariation.Culture) - .Done() + .WithAlias("variantLabel") + .WithName("Variant Label") + .WithDataTypeId(Constants.DataTypes.LabelString) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) + .WithVariations(ContentVariation.Culture) + .Done() .Build(); contentType.AllowedAsRoot = true; ContentTypeService.Save(contentType); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs index 5951af2bb5..42b8f6297b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs @@ -51,7 +51,7 @@ public partial class ContentPublishingServiceTests [TestCase(PublishBranchFilter.All)] public async Task Publish_Branch_Does_Not_Publish_Unpublished_Children_Unless_Instructed_To(PublishBranchFilter publishBranchFilter) { - var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, publishBranchFilter, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, publishBranchFilter, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); @@ -81,7 +81,7 @@ public partial class ContentPublishingServiceTests ContentService.Save(subpage2Subpage, -1); VerifyIsNotPublished(Subpage2.Key); - var result = await ContentPublishingService.PublishBranchAsync(Subpage2.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(Subpage2.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); AssertBranchResultSuccess(result.Result, Subpage2.Key, subpage2Subpage.Key); @@ -192,7 +192,7 @@ public partial class ContentPublishingServiceTests child.SetValue("title", "DA child title", culture: langDa.IsoCode); ContentService.Save(child); - var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode, langDa.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode, langDa.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); AssertBranchResultSuccess(result.Result, root.Key, child.Key); @@ -254,7 +254,7 @@ public partial class ContentPublishingServiceTests child.SetValue("title", "DA child title", culture: langDa.IsoCode); ContentService.Save(child); - var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); AssertBranchResultSuccess(result.Result, root.Key, child.Key); @@ -293,7 +293,7 @@ public partial class ContentPublishingServiceTests child.SetValue("title", "DA child title", culture: langDa.IsoCode); ContentService.Save(child); - var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); AssertBranchResultSuccess(result.Result, root.Key, child.Key); @@ -416,7 +416,7 @@ public partial class ContentPublishingServiceTests public async Task Cannot_Publish_Branch_Of_Non_Existing_Content() { var key = Guid.NewGuid(); - var result = await ContentPublishingService.PublishBranchAsync(key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsFalse(result); AssertBranchResultFailed(result.Result, (key, ContentPublishingOperationStatus.ContentNotFound)); } @@ -448,7 +448,7 @@ public partial class ContentPublishingServiceTests ContentService.Save(child, -1); Assert.AreEqual(content.Id, ContentService.GetById(child.Key)!.ParentId); - var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsFalse(result.Success); AssertBranchResultSuccess(result.Result, Textpage.Key, Subpage.Key, Subpage2.Key, Subpage3.Key); @@ -529,7 +529,7 @@ public partial class ContentPublishingServiceTests child.SetValue("title", "DA child title", culture: langDa.IsoCode); ContentService.Save(child); - var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode, langDa.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode, langDa.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsFalse(result.Success); AssertBranchResultFailed(result.Result, (root.Key, ContentPublishingOperationStatus.ContentInvalid)); @@ -622,7 +622,7 @@ public partial class ContentPublishingServiceTests [Test] public async Task Cannot_Republish_Branch_After_Adding_Mandatory_Property() { - var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); VerifyIsPublished(Textpage.Key); VerifyIsPublished(Subpage.Key); @@ -652,7 +652,7 @@ public partial class ContentPublishingServiceTests textPage.SetValue("mandatoryProperty", "This is a valid value"); ContentService.Save(textPage); - result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsFalse(result.Success); Assert.AreEqual(ContentPublishingOperationStatus.FailedBranch, result.Status); AssertBranchResultSuccess(result.Result, Textpage.Key); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Unpublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Unpublish.cs index 38c3c03829..c292727785 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Unpublish.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Unpublish.cs @@ -1,14 +1,22 @@ +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.Core.Services.OperationStatus; 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.Infrastructure.Services; public partial class ContentPublishingServiceTests { + public static new void ConfigureDisableUnpublishWhenReferencedTrue(IUmbracoBuilder builder) + => builder.Services.Configure(config => + config.DisableUnpublishWhenReferenced = true); + [Test] public async Task Can_Unpublish_Root() { @@ -92,6 +100,40 @@ public partial class ContentPublishingServiceTests VerifyIsPublished(Textpage.Key); } + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableUnpublishWhenReferencedTrue))] + public async Task Cannot_Unpublish_When_Content_Is_Related_As_A_Child_And_Configured_To_Disable_When_Related() + { + await ContentPublishingService.PublishAsync(Textpage.Key, MakeModel(_allCultures), Constants.Security.SuperUserKey); + VerifyIsPublished(Textpage.Key); + + // Setup a relation where the page being unpublished is related to another page as a child (e.g. the other page has a picker and has selected this page). + RelationService.Relate(Subpage, Textpage, Constants.Conventions.RelationTypes.RelatedDocumentAlias); + + var result = await ContentPublishingService.UnpublishAsync(Textpage.Key, null, Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentPublishingOperationStatus.CannotUnpublishWhenReferenced, result.Result); + VerifyIsPublished(Textpage.Key); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableUnpublishWhenReferencedTrue))] + public async Task Can_Unpublish_When_Content_Is_Related_As_A_Parent_And_Configured_To_Disable_When_Related() + { + await ContentPublishingService.PublishAsync(Textpage.Key, MakeModel(_allCultures), Constants.Security.SuperUserKey); + VerifyIsPublished(Textpage.Key); + + // Setup a relation where the page being unpublished is related to another page as a parent (e.g. this page has a picker and has selected the other page). + RelationService.Relate(Textpage, Subpage, Constants.Conventions.RelationTypes.RelatedDocumentAlias); + + var result = await ContentPublishingService.UnpublishAsync(Textpage.Key, null, Constants.Security.SuperUserKey); + + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result); + VerifyIsNotPublished(Textpage.Key); + } + [Test] public async Task Can_Unpublish_Single_Culture() { @@ -229,8 +271,6 @@ public partial class ContentPublishingServiceTests Assert.AreEqual(0, content.PublishedCultures.Count()); } - - [Test] public async Task Can_Unpublish_Non_Mandatory_Cultures() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs index 6e5cf55fc7..fcfbd65b1b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -20,6 +20,8 @@ public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithC { private IContentPublishingService ContentPublishingService => GetRequiredService(); + private IRelationService RelationService => GetRequiredService(); + private static readonly ISet _allCultures = new HashSet(){ "*" }; private static CultureAndScheduleModel MakeModel(ISet cultures) => new CultureAndScheduleModel() diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs index 8cb866bcdd..6870f301c5 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs @@ -30,6 +30,8 @@ internal sealed class DataTypeServiceTests : UmbracoIntegrationTest private IContentTypeService ContentTypeService => GetRequiredService(); + private IMediaTypeService MediaTypeService => GetRequiredService(); + private IFileService FileService => GetRequiredService(); private IConfigurationEditorJsonSerializer ConfigurationEditorJsonSerializer => @@ -446,4 +448,43 @@ internal sealed class DataTypeServiceTests : UmbracoIntegrationTest Assert.IsFalse(result.Success); Assert.AreEqual(DataTypeOperationStatus.NonDeletable, result.Status); } + + [Test] + public async Task DataTypeService_Can_Get_References() + { + IEnumerable dataTypeDefinitions = await DataTypeService.GetByEditorAliasAsync(Constants.PropertyEditors.Aliases.RichText); + + IContentType documentType = ContentTypeBuilder.CreateSimpleContentType("umbTextpage", "Text Page"); + ContentTypeService.Save(documentType); + + IMediaType mediaType = MediaTypeBuilder.CreateSimpleMediaType("umbMediaItem", "Media Item"); + MediaTypeService.Save(mediaType); + + documentType = ContentTypeService.Get(documentType.Id); + Assert.IsNotNull(documentType.PropertyTypes.SingleOrDefault(pt => pt.PropertyEditorAlias is Constants.PropertyEditors.Aliases.RichText)); + + mediaType = MediaTypeService.Get(mediaType.Id); + Assert.IsNotNull(mediaType.PropertyTypes.SingleOrDefault(pt => pt.PropertyEditorAlias is Constants.PropertyEditors.Aliases.RichText)); + + var definition = dataTypeDefinitions.First(); + var definitionKey = definition.Key; + PagedModel result = await DataTypeService.GetPagedRelationsAsync(definitionKey, 0, 10); + Assert.AreEqual(2, result.Total); + + RelationItemModel firstResult = result.Items.First(); + Assert.AreEqual("umbTextpage", firstResult.ContentTypeAlias); + Assert.AreEqual("Text Page", firstResult.ContentTypeName); + Assert.AreEqual("icon-document", firstResult.ContentTypeIcon); + Assert.AreEqual(documentType.Key, firstResult.ContentTypeKey); + Assert.AreEqual("bodyText", firstResult.NodeAlias); + Assert.AreEqual("Body text", firstResult.NodeName); + + RelationItemModel secondResult = result.Items.Skip(1).First(); + Assert.AreEqual("umbMediaItem", secondResult.ContentTypeAlias); + Assert.AreEqual("Media Item", secondResult.ContentTypeName); + Assert.AreEqual("icon-picture", secondResult.ContentTypeIcon); + Assert.AreEqual(mediaType.Key, secondResult.ContentTypeKey); + Assert.AreEqual("bodyText", secondResult.NodeAlias); + Assert.AreEqual("Body text", secondResult.NodeName); + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs index 2c4a44252f..3cf71467e4 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs @@ -1,19 +1,18 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.ContentTypeEditing; using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; using Umbraco.Cms.Tests.Common.Attributes; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; -using Umbraco.Extensions; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; @@ -35,7 +34,7 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest await LanguageService.CreateAsync(_langEs, Constants.Security.SuperUserKey); } - CreateTestData(); + await CreateTestData(); } private Language? _langFr; @@ -57,6 +56,10 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest private IFileService FileService => GetRequiredService(); + private IContentTypeContainerService ContentTypeContainerService => GetRequiredService(); + + public IContentTypeEditingService ContentTypeEditingService => GetRequiredService(); + [Test] public void EntityService_Can_Get_Paged_Descendants_Ordering_Path() { @@ -381,6 +384,43 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest Assert.That(total, Is.EqualTo(10)); } + [Test] + public async Task EntityService_Can_Get_Paged_Document_Type_Children() + { + IEnumerable children = EntityService.GetPagedChildren( + _documentTypeRootContainerKey, + [UmbracoObjectTypes.DocumentTypeContainer], + [UmbracoObjectTypes.DocumentTypeContainer, UmbracoObjectTypes.DocumentType], + 0, + 10, + false, + out long totalRecords); + + Assert.AreEqual(3, totalRecords); + Assert.AreEqual(3, children.Count()); + Assert.IsTrue(children.Single(x => x.Key == _documentTypeSubContainer1Key).HasChildren); // Has a single folder as a child. + Assert.IsTrue(children.Single(x => x.Key == _documentTypeSubContainer2Key).HasChildren); // Has a single document type as a child. + Assert.IsFalse(children.Single(x => x.Key == _documentType1Key).HasChildren); // Is a document type (has no children). + } + + [Test] + public async Task EntityService_Can_Get_Paged_Document_Type_Children_For_Folders_Only() + { + IEnumerable children = EntityService.GetPagedChildren( + _documentTypeRootContainerKey, + [UmbracoObjectTypes.DocumentTypeContainer], + [UmbracoObjectTypes.DocumentTypeContainer], + 0, + 10, + false, + out long totalRecords); + + Assert.AreEqual(2, totalRecords); + Assert.AreEqual(2, children.Count()); + Assert.IsTrue(children.Single(x => x.Key == _documentTypeSubContainer1Key).HasChildren); // Has a single folder as a child. + Assert.IsFalse(children.Single(x => x.Key == _documentTypeSubContainer2Key).HasChildren); // Has a single document type as a child. + } + [Test] [LongRunning] public void EntityService_Can_Get_Paged_Media_Descendants() @@ -738,7 +778,7 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var entities = EntityService.GetAll(UmbracoObjectTypes.DocumentType).ToArray(); Assert.That(entities.Any(), Is.True); - Assert.That(entities.Count(), Is.EqualTo(1)); + Assert.That(entities.Count(), Is.EqualTo(3)); } [Test] @@ -748,7 +788,7 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var entities = EntityService.GetAll(objectTypeId).ToArray(); Assert.That(entities.Any(), Is.True); - Assert.That(entities.Count(), Is.EqualTo(1)); + Assert.That(entities.Count(), Is.EqualTo(3)); } [Test] @@ -757,7 +797,7 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var entities = EntityService.GetAll().ToArray(); Assert.That(entities.Any(), Is.True); - Assert.That(entities.Count(), Is.EqualTo(1)); + Assert.That(entities.Count(), Is.EqualTo(3)); } [Test] @@ -870,6 +910,27 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest Assert.IsFalse(EntityService.GetId(Guid.NewGuid(), UmbracoObjectTypes.DocumentType).Success); } + [Test] + public void EntityService_GetPathKeys_ReturnsExpectedKeys() + { + var contentType = ContentTypeService.Get("umbTextpage"); + + var root = ContentBuilder.CreateSimpleContent(contentType); + ContentService.Save(root); + + var child = ContentBuilder.CreateSimpleContent(contentType, Guid.NewGuid().ToString(), root); + ContentService.Save(child); + var grandChild = ContentBuilder.CreateSimpleContent(contentType, Guid.NewGuid().ToString(), child); + ContentService.Save(grandChild); + + var result = EntityService.GetPathKeys(grandChild); + Assert.AreEqual($"{root.Key},{child.Key},{grandChild.Key}", string.Join(",", result)); + + var result2 = EntityService.GetPathKeys(grandChild, omitSelf: true); + Assert.AreEqual($"{root.Key},{child.Key}", string.Join(",", result2)); + + } + private static bool _isSetup; private int _folderId; @@ -885,7 +946,7 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest private Media _subfolder; private Media _subfolder2; - public void CreateTestData() + public async Task CreateTestData() { if (_isSetup == false) { @@ -942,6 +1003,38 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest // Create and save sub folder -> 1061 _subfolder2 = MediaBuilder.CreateMediaFolder(_folderMediaType, _subfolder.Id); MediaService.Save(_subfolder2, -1); + + // Setup document type folder structure for tests on paged children with or without folders + await CreateStructureForPagedDocumentTypeChildrenTest(); } } + + private static readonly Guid _documentTypeRootContainerKey = Guid.NewGuid(); + private static readonly Guid _documentTypeSubContainer1Key = Guid.NewGuid(); + private static readonly Guid _documentTypeSubContainer2Key = Guid.NewGuid(); + private static readonly Guid _documentType1Key = Guid.NewGuid(); + + private async Task CreateStructureForPagedDocumentTypeChildrenTest() + { + // Structure created: + // - root container + // - sub container 1 + // - sub container 1b + // - sub container 2 + // - doc type 2 + // - doc type 1 + await ContentTypeContainerService.CreateAsync(_documentTypeRootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + await ContentTypeContainerService.CreateAsync(_documentTypeSubContainer1Key, "Sub Container 1", _documentTypeRootContainerKey, Constants.Security.SuperUserKey); + await ContentTypeContainerService.CreateAsync(_documentTypeSubContainer2Key, "Sub Container 2", _documentTypeRootContainerKey, Constants.Security.SuperUserKey); + await ContentTypeContainerService.CreateAsync(Guid.NewGuid(), "Sub Container 1b", _documentTypeSubContainer1Key, Constants.Security.SuperUserKey); + + var docType1Model = ContentTypeEditingBuilder.CreateBasicContentType("umbDocType1", "Doc Type 1"); + docType1Model.ContainerKey = _documentTypeRootContainerKey; + docType1Model.Key = _documentType1Key; + await ContentTypeEditingService.CreateAsync(docType1Model, Constants.Security.SuperUserKey); + + var docType2Model = ContentTypeEditingBuilder.CreateBasicContentType("umbDocType2", "Doc Type 2"); + docType2Model.ContainerKey = _documentTypeSubContainer2Key; + await ContentTypeEditingService.CreateAsync(docType2Model, Constants.Security.SuperUserKey); + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs index b033345576..3e74f5ccb5 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs @@ -80,7 +80,7 @@ internal class TrackedReferencesServiceTests : UmbracoIntegrationTest } [Test] - public async Task Does_not_return_references_if_item_is_not_referenced() + public async Task Does_Not_Return_References_If_Item_Is_Not_Referenced() { var sut = GetRequiredService(); @@ -88,4 +88,22 @@ internal class TrackedReferencesServiceTests : UmbracoIntegrationTest Assert.AreEqual(0, actual.Total); } + + [Test] + public async Task Get_Pages_That_Reference_Recycle_Bin_Contents() + { + ContentService.MoveToRecycleBin(Root1); + + var sut = GetRequiredService(); + + var actual = await sut.GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes.Document, 0, 10, true); + + Assert.Multiple(() => + { + Assert.AreEqual(1, actual.Total); + var item = actual.Items.FirstOrDefault(); + Assert.AreEqual(Root2.ContentType.Alias, item?.ContentTypeAlias); + Assert.AreEqual(Root2.Key, item?.NodeKey); + }); + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 9efd93a4bc..289d431f45 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -1,4 +1,4 @@ - + true Umbraco.Cms.Tests.Integration @@ -9,7 +9,7 @@ $(BaseEnablePackageValidation) $(NoWarn),NU5100 - + $(WarningsNotAsErrors),CS0108,SYSLIB0012,CS0618,SA1116,SA1117,CS0162,CS0169,SA1134,SA1405,CS4014,CS1998,CS0649,CS0168 - + @@ -97,6 +97,9 @@ ContentEditingServiceTests.cs + + ContentEditingServiceTests.cs + ContentPublishingServiceTests.cs @@ -187,6 +190,24 @@ BlockListElementLevelVariationTests.cs + + ContentPublishingServiceTests.cs + + + ContentPublishingServiceTests.cs + + + ContentPublishingServiceTests.cs + + + ContentPublishingServiceTests.cs + + + ContentPublishingServiceTests.cs + + + ContentPublishingServiceTests.cs + DocumentNavigationServiceTests.cs @@ -253,5 +274,17 @@ PublishedUrlInfoProviderTestsBase.cs + + UserStartNodeEntitiesServiceTests.cs + + + UserStartNodeEntitiesServiceTests.cs + + + UserStartNodeEntitiesServiceMediaTests.cs + + + UserStartNodeEntitiesServiceMediaTests.cs + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs index 9edc858532..879400f79a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs @@ -75,6 +75,14 @@ public class BlockListEditorPropertyValueEditorTests } } + [Test] + public void MergeVariantInvariantPropertyValue_Can_Merge_Null_Values() + { + var editor = CreateValueEditor(); + var result = editor.MergeVariantInvariantPropertyValue(null, null, true, ["en-US"]); + Assert.IsNull(result); + } + private static JsonObject CreateBlocksJson(int numberOfBlocks) { var layoutItems = new JsonArray(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringPropertyValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringPropertyValueEditorTests.cs index c3a814075a..e7544fe08d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringPropertyValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringPropertyValueEditorTests.cs @@ -82,6 +82,20 @@ public class MultipleTextStringPropertyValueEditorTests Assert.AreEqual("The First Value\nThe Second Value\nThe Third Value", fromEditor); } + [Test] + public void Can_Parse_More_Items_Than_Allowed_From_Editor() + { + var valueEditor = CreateValueEditor(); + var fromEditor = valueEditor.FromEditor(new ContentPropertyData(new[] { "One", "Two", "Three", "Four", "Five" }, new MultipleTextStringConfiguration { Max = 4 }), null) as string; + Assert.AreEqual("One\nTwo\nThree\nFour\nFive", fromEditor); + + var validationResults = valueEditor.Validate(fromEditor, false, null, PropertyValidationContext.Empty()); + Assert.AreEqual(1, validationResults.Count()); + + var validationResult = validationResults.First(); + Assert.AreEqual($"validation_outOfRangeMultipleItemsMaximum", validationResult.ErrorMessage); + } + [Test] public void Can_Parse_Single_Value_To_Editor() { @@ -150,6 +164,27 @@ public class MultipleTextStringPropertyValueEditorTests } } + [TestCase("", false)] + [TestCase("one", false)] + [TestCase("one\ntwo", true)] + [TestCase("one\ntwo\nthree", true)] + public void Validates_Number_Of_Items_Is_Greater_Than_Or_Equal_To_Configured_Min_Raw_Property_Value(string value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual($"validation_outOfRangeSingleItemMinimum", validationResult.ErrorMessage); + } + } + [TestCase(3, true)] [TestCase(4, true)] [TestCase(5, false)] @@ -171,6 +206,36 @@ public class MultipleTextStringPropertyValueEditorTests } } + [TestCase("one\ntwo\nthree", true)] + [TestCase("one\ntwo\nthree\nfour", true)] + [TestCase("one\ntwo\nthree\nfour\nfive", false)] + public void Validates_Number_Of_Items_Is_Less_Than_Or_Equal_To_Configured_Max_Raw_Property_Value(string value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual("validation_outOfRangeMultipleItemsMaximum", validationResult.ErrorMessage); + } + } + + [TestCase("one\ntwo\nthree")] + [TestCase("one\rtwo\rthree")] + [TestCase("one\r\ntwo\r\nthree")] + public void Can_Parse_Supported_Property_Value_Delimiters(string value) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + Assert.IsEmpty(result); + } + [Test] public void Max_Item_Validation_Respects_0_As_Unlimited() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByRedirectUrlTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByRedirectUrlTests.cs new file mode 100644 index 0000000000..025a1ef655 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByRedirectUrlTests.cs @@ -0,0 +1,162 @@ +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Routing; + +[TestFixture] +public class ContentFinderByRedirectUrlTests +{ + private const int DomainContentId = 1233; + private const int ContentId = 1234; + + [Test] + public async Task Can_Find_Invariant_Content() + { + const string OldPath = "/old-page-path"; + const string NewPath = "/new-page-path"; + + var mockRedirectUrlService = CreateMockRedirectUrlService(OldPath); + + var mockContent = CreateMockPublishedContent(); + + var mockUmbracoContextAccessor = CreateMockUmbracoContextAccessor(mockContent); + + var mockPublishedUrlProvider = CreateMockPublishedUrlProvider(NewPath); + + var sut = CreateContentFinder(mockRedirectUrlService, mockUmbracoContextAccessor, mockPublishedUrlProvider); + + var publishedRequestBuilder = CreatePublishedRequestBuilder(OldPath); + + var result = await sut.TryFindContent(publishedRequestBuilder); + + AssertRedirectResult(publishedRequestBuilder, result); + } + + [Test] + public async Task Can_Find_Variant_Content_With_Path_Root() + { + const string OldPath = "/en/old-page-path"; + const string NewPath = "/en/new-page-path"; + + var mockRedirectUrlService = CreateMockRedirectUrlService(OldPath); + + var mockContent = CreateMockPublishedContent(); + + var mockUmbracoContextAccessor = CreateMockUmbracoContextAccessor(mockContent); + + var mockPublishedUrlProvider = CreateMockPublishedUrlProvider(NewPath); + + var sut = CreateContentFinder(mockRedirectUrlService, mockUmbracoContextAccessor, mockPublishedUrlProvider); + + var publishedRequestBuilder = CreatePublishedRequestBuilder(OldPath, withDomain: true); + + var result = await sut.TryFindContent(publishedRequestBuilder); + + AssertRedirectResult(publishedRequestBuilder, result); + } + + [Test] + public async Task Can_Find_Variant_Content_With_Domain_Node_Id_Prefixed_Path() + { + const string OldPath = "/en/old-page-path"; + var domainPrefixedOldPath = $"{DomainContentId}/old-page-path"; + const string NewPath = "/en/new-page-path"; + + var mockRedirectUrlService = CreateMockRedirectUrlService(domainPrefixedOldPath); + + var mockContent = CreateMockPublishedContent(); + + var mockUmbracoContextAccessor = CreateMockUmbracoContextAccessor(mockContent); + + var mockPublishedUrlProvider = CreateMockPublishedUrlProvider(NewPath); + + var sut = CreateContentFinder(mockRedirectUrlService, mockUmbracoContextAccessor, mockPublishedUrlProvider); + + var publishedRequestBuilder = CreatePublishedRequestBuilder(OldPath, withDomain: true); + + var result = await sut.TryFindContent(publishedRequestBuilder); + + AssertRedirectResult(publishedRequestBuilder, result); + } + + private static Mock CreateMockRedirectUrlService(string oldPath) + { + var mockRedirectUrlService = new Mock(); + mockRedirectUrlService + .Setup(x => x.GetMostRecentRedirectUrlAsync(It.Is(y => y == oldPath), It.IsAny())) + .ReturnsAsync(new RedirectUrl + { + ContentId = ContentId, + }); + return mockRedirectUrlService; + } + + private static Mock CreateMockPublishedUrlProvider(string newPath) + { + var mockPublishedUrlProvider = new Mock(); + mockPublishedUrlProvider + .Setup(x => x.GetUrl(It.Is(y => y.Id == ContentId), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(newPath); + return mockPublishedUrlProvider; + } + + private static Mock CreateMockPublishedContent() + { + var mockContent = new Mock(); + mockContent + .SetupGet(x => x.Id) + .Returns(ContentId); + mockContent + .SetupGet(x => x.ContentType.ItemType) + .Returns(PublishedItemType.Content); + return mockContent; + } + + private static Mock CreateMockUmbracoContextAccessor(Mock mockContent) + { + var mockUmbracoContext = new Mock(); + mockUmbracoContext + .Setup(x => x.Content.GetById(It.Is(y => y == ContentId))) + .Returns(mockContent.Object); + var mockUmbracoContextAccessor = new Mock(); + var umbracoContext = mockUmbracoContext.Object; + mockUmbracoContextAccessor + .Setup(x => x.TryGetUmbracoContext(out umbracoContext)) + .Returns(true); + return mockUmbracoContextAccessor; + } + + private static ContentFinderByRedirectUrl CreateContentFinder( + Mock mockRedirectUrlService, + Mock mockUmbracoContextAccessor, + Mock mockPublishedUrlProvider) + => new ContentFinderByRedirectUrl( + mockRedirectUrlService.Object, + new NullLogger(), + mockPublishedUrlProvider.Object, + mockUmbracoContextAccessor.Object); + + private static PublishedRequestBuilder CreatePublishedRequestBuilder(string path, bool withDomain = false) + { + var publishedRequestBuilder = new PublishedRequestBuilder(new Uri($"https://example.com{path}"), Mock.Of()); + if (withDomain) + { + publishedRequestBuilder.SetDomain(new DomainAndUri(new Domain(1, "/en", DomainContentId, "en-US", false, 0), new Uri($"https://example.com{path}"))); + } + + return publishedRequestBuilder; + } + + private static void AssertRedirectResult(PublishedRequestBuilder publishedRequestBuilder, bool result) + { + Assert.AreEqual(true, result); + Assert.AreEqual(HttpStatusCode.Moved, (HttpStatusCode)publishedRequestBuilder.ResponseStatusCode); + } +}