diff --git a/src/Umbraco.Cms.Api.Common/ViewModels/Pagination/SubsetViewModel.cs b/src/Umbraco.Cms.Api.Common/ViewModels/Pagination/SubsetViewModel.cs new file mode 100644 index 0000000000..34a05dfdfb --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/ViewModels/Pagination/SubsetViewModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Api.Common.ViewModels.Pagination; + +public class SubsetViewModel +{ + [Required] + public long TotalBefore { get; set; } + + [Required] + public long TotalAfter { get; set; } + + [Required] + public IEnumerable Items { get; set; } = Enumerable.Empty(); + + public static SubsetViewModel Empty() => new(); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs index 253d32e3e8..310bbb4563 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,7 +14,7 @@ public class SiblingsDataTypeTreeController : DataTypeTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) => GetSiblings(target, before, after); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs index 70445d525f..cf2f1b4f38 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs @@ -1,6 +1,7 @@ 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.Services.Entities; using Umbraco.Cms.Api.Management.ViewModels.Tree; @@ -34,8 +35,8 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase [HttpGet("siblings")] [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) { IgnoreUserStartNodesForDataType(dataTypeId); return GetSiblings(target, before, after); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs index ac5578155f..528d494f77 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs @@ -1,5 +1,6 @@ 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.Tree; using Umbraco.Cms.Core.Services; @@ -14,8 +15,8 @@ public class SiblingsDocumentBlueprintTreeController : DocumentBlueprintTreeCont } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings( + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings( CancellationToken cancellationToken, Guid target, int before, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs index 7bb9c26358..9de9f5e1bc 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,8 +14,8 @@ public class SiblingsDocumentTypeTreeController : DocumentTypeTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings( + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings( CancellationToken cancellationToken, Guid target, int before, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs index 4cc855fa6d..8195d03cac 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs @@ -1,5 +1,6 @@ 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.Services.Entities; using Umbraco.Cms.Api.Management.ViewModels.Tree; @@ -23,8 +24,8 @@ public class SiblingsMediaTreeController : MediaTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) { IgnoreUserStartNodesForDataType(dataTypeId); return GetSiblings(target, before, after); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs index 1482788b57..4b445ea889 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,7 +14,7 @@ public class SiblingsMediaTypeTreeController : MediaTypeTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) => GetSiblings(target, before, after); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs index ed27092ecb..60390caa22 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,8 +14,8 @@ public class SiblingsTemplateTreeController : TemplateTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings( + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings( CancellationToken cancellationToken, Guid target, int before, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs index 9b35fb2923..80c39c5fa0 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs @@ -44,12 +44,12 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB return Task.FromResult>>(Ok(result)); } - protected Task>> GetSiblings(Guid target, int before, int after) + protected Task>> GetSiblings(Guid target, int before, int after) { - IEntitySlim[] siblings = GetSiblingEntities(target, before, after); + IEntitySlim[] siblings = GetSiblingEntities(target, before, after, out var totalBefore, out var totalAfter); if (siblings.Length == 0) { - return Task.FromResult>>(NotFound()); + return Task.FromResult>>(NotFound()); } IEntitySlim? entity = siblings.FirstOrDefault(); @@ -57,8 +57,11 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB ? EntityService.GetKey(entity.ParentId, ItemObjectType).Result : Constants.System.RootKey; - TItem[] treeItemsViewModels = MapTreeItemViewModels(parentKey, siblings); - return Task.FromResult>>(Ok(treeItemsViewModels)); + TItem[] treeItemViewModels = MapTreeItemViewModels(parentKey, siblings); + + SubsetViewModel result = SubsetViewModel(treeItemViewModels, totalBefore, totalAfter); + + return Task.FromResult>>(Ok(result)); } protected virtual async Task>> GetAncestors(Guid descendantKey, bool includeSelf = true) @@ -120,13 +123,15 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB ordering: ItemOrdering) .ToArray(); - protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after) => + protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) => EntityService .GetSiblings( target, ItemObjectType, before, after, + out totalBefore, + out totalAfter, ordering: ItemOrdering) .ToArray(); @@ -152,4 +157,7 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB protected PagedViewModel PagedViewModel(IEnumerable treeItemViewModels, long totalItems) => new() { Total = totalItems, Items = treeItemViewModels }; + + protected SubsetViewModel SubsetViewModel(IEnumerable treeItemViewModels, long totalBefore, long totalAfter) + => new() { TotalBefore = totalBefore, TotalAfter = totalAfter, Items = treeItemViewModels }; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs index 505330bdbd..6f95e6210e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs @@ -59,11 +59,11 @@ public abstract class UserStartNodeTreeControllerBase : EntityTreeControl return CalculateAccessMap(() => userAccessEntities, out _); } - protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after) + protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) { if (UserHasRootAccess() || IgnoreUserStartNodes()) { - return base.GetSiblingEntities(target, before, after); + return base.GetSiblingEntities(target, before, after, out totalBefore, out totalAfter); } IEnumerable userAccessEntities = _userStartNodeEntitiesService.SiblingUserAccessEntities( @@ -72,7 +72,9 @@ public abstract class UserStartNodeTreeControllerBase : EntityTreeControl target, before, after, - ItemOrdering); + ItemOrdering, + out totalBefore, + out totalAfter); return CalculateAccessMap(() => userAccessEntities, out _); } diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 1cd05c0cd8..2f0dde606a 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -1839,14 +1839,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DataTypeTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDataTypeTreeItemResponseModel" + } + ] } } } @@ -4453,14 +4450,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentBlueprintTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDocumentBlueprintTreeItemResponseModel" + } + ] } } } @@ -6770,14 +6764,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTypeTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDocumentTypeTreeItemResponseModel" + } + ] } } } @@ -11208,6 +11199,14 @@ "type": "integer", "format": "int32" } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -11216,14 +11215,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDocumentTreeItemResponseModel" + } + ] } } } @@ -16028,14 +16024,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTypeTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetMediaTypeTreeItemResponseModel" + } + ] } } } @@ -18550,6 +18543,14 @@ "type": "integer", "format": "int32" } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -18558,14 +18559,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetMediaTreeItemResponseModel" + } + ] } } } @@ -28769,14 +28767,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/NamedEntityTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetNamedEntityTreeItemResponseModel" + } + ] } } } @@ -36735,6 +36730,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -37021,6 +37019,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -38357,6 +38358,36 @@ }, "additionalProperties": false }, + "DocumentTypePermissionPresentationModel": { + "required": [ + "$type", + "documentTypeAlias", + "verbs" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "verbs": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "documentTypeAlias": { + "type": "string" + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTypePermissionPresentationModel": "#/components/schemas/DocumentTypePermissionPresentationModel" + } + } + }, "DocumentTypePropertyTypeContainerResponseModel": { "required": [ "id", @@ -44776,6 +44807,209 @@ }, "additionalProperties": false }, + "SubsetDataTypeTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DataTypeTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetDocumentBlueprintTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentBlueprintTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetDocumentTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetDocumentTypeTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetMediaTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetMediaTypeTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTypeTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetNamedEntityTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/NamedEntityTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "TagResponseModel": { "required": [ "id", @@ -46514,6 +46748,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -46940,6 +47177,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs index 5fea446bec..58e758f5c2 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs @@ -73,6 +73,8 @@ public interface IUserStartNodeEntitiesService /// The number of applicable siblings to retrieve before the target. /// The number of applicable siblings to retrieve after the target. /// The ordering to apply when fetching and paginating the children. + /// Outputs the total number of siblings before the target entity. + /// Outputs the total number of siblings after the target entity. /// A list of sibling entities applicable for the user. /// /// The returned entities may include entities that outside of the user start node scope, but are needed to @@ -84,7 +86,14 @@ public interface IUserStartNodeEntitiesService Guid targetKey, int before, int after, - Ordering ordering) => []; + Ordering ordering, + out long totalBefore, + out long totalAfter) + { + totalBefore = 0; + totalAfter = 0; + return []; + } /// /// Calculates the access level of a collection of 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 6d53d2ef38..89bd1ef981 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs @@ -64,7 +64,14 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService } /// - public IEnumerable ChildUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid parentKey, int skip, int take, Ordering ordering, out long totalItems) + 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) @@ -142,11 +149,22 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService }).WhereNotNull().ToArray(); /// - public IEnumerable SiblingUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid targetKey, int before, int after, Ordering ordering) + public IEnumerable SiblingUserAccessEntities( + UmbracoObjectTypes umbracoObjectType, + string[] userStartNodePaths, + Guid targetKey, + int before, + int after, + Ordering ordering, + out long totalBefore, + out long totalAfter + ) { Attempt targetIdAttempt = _idKeyMap.GetIdForKey(targetKey, umbracoObjectType); if (targetIdAttempt.Success is false) { + totalBefore = 0; + totalAfter = 0; return []; } @@ -154,6 +172,8 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService IEntitySlim? target = _entityService.Get(targetId); if (target is null) { + totalBefore = 0; + totalAfter = 0; return []; } @@ -162,13 +182,15 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService IEntitySlim? targetParent = _entityService.Get(target.ParentId); if (targetParent is null) // Even if the parent is the root, we still expect to get a value here. { + totalBefore = 0; + totalAfter = 0; return []; } if (userStartNodePaths.Any(path => $"{targetParent?.Path},".StartsWith($"{path},"))) { // The requested parent of the target is one of the user start nodes (or a descendant of one), all siblings are by definition allowed. - siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, ordering: ordering).ToArray(); + siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, out totalBefore, out totalAfter, ordering: ordering).ToArray(); return ChildUserAccessEntities(siblings, userStartNodePaths); } @@ -177,12 +199,14 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService if (allowedSiblingIds.Length == 0) { // The requested target is outside the scope of any user start nodes. + totalBefore = 0; + totalAfter = 0; return []; } // Even though we know the IDs of the allowed sibling entities to fetch, we still use a Query to yield correctly sorted children. IQuery query = _scopeProvider.CreateQuery().Where(x => allowedSiblingIds.Contains(x.Id)); - siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, query, ordering).ToArray(); + siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, out totalBefore, out totalAfter, query, ordering).ToArray(); return ChildUserAccessEntities(siblings, userStartNodePaths); } diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index 31b9c53983..0ad4e42260 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -28,8 +28,23 @@ public interface IEntityRepository : IRepository /// The number of siblings to retrieve after the target entity. /// An optional filter to apply to the result set. /// The ordering to apply to the siblings. + /// Outputs the total number of siblings before the target entity. + /// Outputs the total number of siblings after the target entity. /// Enumerable of sibling entities. - IEnumerable GetSiblings(Guid objectType, Guid targetKey, int before, int after, IQuery? filter, Ordering ordering) => []; + IEnumerable GetSiblings( + Guid objectType, + Guid targetKey, + int before, + int after, + IQuery? filter, + Ordering ordering, + out long totalBefore, + out long totalAfter) + { + totalBefore = 0; + totalAfter = 0; + return []; + } /// /// Gets entities for a query diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index f9408398e1..9778e58a32 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -324,6 +324,8 @@ public class EntityService : RepositoryService, IEntityService UmbracoObjectTypes objectType, int before, int after, + out long totalBefore, + out long totalAfter, IQuery? filter = null, Ordering? ordering = null) { @@ -347,7 +349,9 @@ public class EntityService : RepositoryService, IEntityService before, after, filter, - ordering); + ordering, + out totalBefore, + out totalAfter); scope.Complete(); return siblings; diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index 1c979f4e4f..768a746b00 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -179,14 +179,23 @@ public interface IEntityService /// The number of siblings to retrieve after the target entity. Needs to be greater or equal to 0. /// An optional filter to apply to the result set. /// The ordering to apply to the siblings. + /// Outputs the total number of siblings before the target entity. + /// Outputs the total number of siblings after the target entity. /// Enumerable of sibling entities. IEnumerable GetSiblings( Guid key, UmbracoObjectTypes objectType, int before, int after, + out long totalBefore, + out long totalAfter, IQuery? filter = null, - Ordering? ordering = null) => []; + Ordering? ordering = null) + { + totalBefore = 0; + totalAfter = 0; + return []; + } /// /// Gets the children of an entity. diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index 6e7349bf81..86bdd88267 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -152,7 +152,9 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend int before, int after, IQuery? filter, - Ordering ordering) + Ordering ordering, + out long totalBefore, + out long totalAfter) { // Ideally we don't want to have to do a second query for the parent ID, but the siblings query is already messy enough // without us also having to do a nested query for the parent ID too. @@ -204,15 +206,21 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend // Select the UniqueId of nodes which row number is within the specified range of the target node's row number. const int BeforeAfterParameterIndex = 3; + var beforeAfterParameterIndex = BeforeAfterParameterIndex + beforeAfterParameterIndexOffset; + var beforeArgumentsArray = beforeArguments.ToArray(); + var afterArgumentsArray = afterArguments.ToArray(); Sql? mainSql = Sql() .Select("UniqueId") .From().AppendSubQuery(rowNumberSql, "NumberedNodes") - .Where($"rn >= ({targetRowSql.SQL}) - @{BeforeAfterParameterIndex + beforeAfterParameterIndexOffset}", beforeArguments.ToArray()) - .Where($"rn <= ({targetRowSql.SQL}) + @{BeforeAfterParameterIndex + beforeAfterParameterIndexOffset}", afterArguments.ToArray()) + .Where($"rn >= ({targetRowSql.SQL}) - @{beforeAfterParameterIndex}", beforeArgumentsArray) + .Where($"rn <= ({targetRowSql.SQL}) + @{beforeAfterParameterIndex}", afterArgumentsArray) .OrderBy("rn"); List? keys = Database.Fetch(mainSql); + totalBefore = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, beforeArgumentsArray, true); + totalAfter = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, afterArgumentsArray, false); + if (keys is null || keys.Count == 0) { return []; @@ -221,6 +229,20 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend return PerformGetAll(objectType, ordering, sql => sql.WhereIn(x => x.UniqueId, keys)); } + private long GetNumberOfSiblingsOutsideSiblingRange( + Sql rowNumberSql, + Sql targetRowSql, + int parameterIndex, + object[] arguments, + bool getBefore) + { + Sql? sql = Sql() + .SelectCount() + .From().AppendSubQuery(rowNumberSql, "NumberedNodes") + .Where($"rn {(getBefore ? "<" : ">")} ({targetRowSql.SQL}) {(getBefore ? "-" : "+")} @{parameterIndex}", arguments); + return Database.ExecuteScalar(sql); + } + public IEntitySlim? Get(Guid key, Guid objectTypeId) { diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs index e481b1c0ac..d545416eb1 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs @@ -17,9 +17,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests _mediaByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(2, totalBefore); + Assert.AreEqual(3, totalAfter); Assert.AreEqual(5, siblings.Length); Assert.Multiple(() => { @@ -45,9 +49,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests _mediaByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { @@ -68,9 +76,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests _mediaByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { @@ -82,18 +94,22 @@ public partial class UserStartNodeEntitiesServiceMediaTests [Test] public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-3"].Id, _mediaByName["1-5"].Id, _mediaByName["1-7"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-3"].Id, _mediaByName["1-5"].Id, _mediaByName["1-7"].Id, _mediaByName["1-10"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, _mediaByName["1-5"].Key, - 2, - 2, - BySortOrder) + 1, + 1, + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(1, totalAfter); Assert.AreEqual(3, siblings.Length); Assert.Multiple(() => { @@ -118,9 +134,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests _mediaByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs index 839a0edc02..9e19fa9800 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs @@ -17,9 +17,13 @@ public partial class UserStartNodeEntitiesServiceTests _contentByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(2, totalBefore); + Assert.AreEqual(3, totalAfter); Assert.AreEqual(5, siblings.Length); Assert.Multiple(() => { @@ -45,9 +49,13 @@ public partial class UserStartNodeEntitiesServiceTests _contentByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { @@ -68,9 +76,13 @@ public partial class UserStartNodeEntitiesServiceTests _contentByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { @@ -82,19 +94,24 @@ public partial class UserStartNodeEntitiesServiceTests [Test] public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-3"].Id, _contentByName["1-5"].Id, _contentByName["1-7"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-3"].Id, _contentByName["1-5"].Id, _contentByName["1-7"].Id, _contentByName["1-10"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, _contentByName["1-5"].Key, - 2, - 2, - BySortOrder) + 1, + 1, + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(1, totalAfter); Assert.AreEqual(3, siblings.Length); + Assert.Multiple(() => { Assert.AreEqual(_contentByName[$"1-3"].Key, siblings[0].Entity.Key); @@ -118,9 +135,13 @@ public partial class UserStartNodeEntitiesServiceTests _contentByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs index 0db7203ad9..644e7a0e05 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs @@ -929,7 +929,6 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var result2 = EntityService.GetPathKeys(grandChild, omitSelf: true); Assert.AreEqual($"{root.Key},{child.Key}", string.Join(",", result2)); - } [Test] @@ -939,7 +938,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var target = children[1]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(7, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[0].Key == children[0].Key); Assert.IsTrue(result[1].Key == children[1].Key); @@ -955,7 +956,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest ContentService.MoveToRecycleBin(trash); var target = children[2]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(6, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsFalse(result.Any(x => x.Key == trash.Key)); Assert.IsTrue(result[0].Key == children[0].Key); @@ -974,7 +977,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest IQuery filter = ScopeProvider.CreateQuery().Where(x => !keysToExclude.Contains(x.Key)); var target = children[2]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, filter).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter, filter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(6, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsFalse(result.Any(x => x.Key == keysToExclude[0])); Assert.IsTrue(result[0].Key == children[0].Key); @@ -993,7 +998,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest IQuery filter = ScopeProvider.CreateQuery().Where(x => x.Key != keyToExclude); var target = children[2]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, filter).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter, filter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(6, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsFalse(result.Any(x => x.Key == keyToExclude)); Assert.IsTrue(result[0].Key == children[0].Key); @@ -1010,7 +1017,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest children = children.OrderBy(x => x.Name).ToList(); var target = children[1]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, ordering: Ordering.By(nameof(NodeDto.Text))).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter, ordering: Ordering.By(nameof(NodeDto.Text))).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(7, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[0].Key == children[0].Key); Assert.IsTrue(result[1].Key == children[1].Key); @@ -1023,7 +1032,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var children = CreateSiblingsTestData(); var target = children[1]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 100, 1).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 100, 1, out long totalBefore, out long totalAfter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(7, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[0].Key == children[0].Key); Assert.IsTrue(result[1].Key == children[1].Key); @@ -1036,7 +1047,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var children = CreateSiblingsTestData(); var target = children[^2]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 100).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 100, out long totalBefore, out long totalAfter).ToArray(); + Assert.AreEqual(7, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[^1].Key == children[^1].Key); Assert.IsTrue(result[^2].Key == children[^2].Key); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs index e333267274..0e8371cb5d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs @@ -23,7 +23,7 @@ public class EntityServiceTests if (shouldThrow) { - Assert.Throws(() => sut.GetSiblings(Guid.NewGuid(), UmbracoObjectTypes.Document, before, after)); + Assert.Throws(() => sut.GetSiblings(Guid.NewGuid(), UmbracoObjectTypes.Document, before, after, out _, out _)); } }