diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs index 84f70e91c49a..c0f7b95bf6c0 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs @@ -11,7 +11,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.DataType; [VersionedApiBackOfficeRoute(Constants.UdiEntityType.DataType)] [ApiExplorerSettings(GroupName = "Data Type")] -[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrMediaOrMembersOrContentTypes)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrElementsOrMediaOrMembersOrContentTypes)] public abstract class DataTypeControllerBase : ManagementApiControllerBase { protected IActionResult DataTypeOperationStatusResult(DataTypeOperationStatus status) => diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs index 10f193131320..6cad2b13bfcd 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs @@ -30,6 +30,7 @@ protected IActionResult DocumentEditingOperationStatusResult( where TContentModelBase : ContentModelBase => ContentEditingOperationStatusResult(status, requestModel, validationResult); + // TODO ELEMENTS: move this to ContentControllerBase protected IActionResult DocumentPublishingOperationStatusResult( ContentPublishingOperationStatus status, IEnumerable? invalidPropertyAliases = null, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/DocumentTypeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/DocumentTypeControllerBase.cs index 80f094cf0324..d7d704c52be6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/DocumentTypeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/DocumentTypeControllerBase.cs @@ -11,7 +11,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.DocumentType; [VersionedApiBackOfficeRoute(Constants.UdiEntityType.DocumentType)] [ApiExplorerSettings(GroupName = "Document Type")] -[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrElementsOrDocumentTypes)] public abstract class DocumentTypeControllerBase : ManagementApiControllerBase { protected IActionResult OperationStatusResult(ContentTypeOperationStatus status) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs index a09554756e77..c4f96990ba95 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs @@ -41,14 +41,17 @@ public async Task All( Attempt?, ContentVersionOperationStatus> attempt = await _contentVersionService.GetPagedContentVersionsAsync(documentId, culture, skip, take); + if (attempt.Success is false) + { + return MapFailure(attempt.Status); + } + var pagedViewModel = new PagedViewModel { Total = attempt.Result!.Total, Items = await _documentVersionPresentationFactory.CreateMultipleAsync(attempt.Result!.Items), }; - return attempt.Success - ? Ok(pagedViewModel) - : MapFailure(attempt.Status); + return Ok(pagedViewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs index 1e31f6a0788c..e201eb284c9a 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs @@ -13,25 +13,26 @@ namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion; [Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)] public abstract class DocumentVersionControllerBase : ManagementApiControllerBase { - protected IActionResult MapFailure(ContentVersionOperationStatus status) + internal static IActionResult MapFailure(ContentVersionOperationStatus status) => OperationStatusResult(status, problemDetailsBuilder => status switch { - ContentVersionOperationStatus.NotFound => NotFound(problemDetailsBuilder + ContentVersionOperationStatus.NotFound => new NotFoundObjectResult(problemDetailsBuilder .WithTitle("The requested version could not be found") .Build()), - ContentVersionOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder + ContentVersionOperationStatus.ContentNotFound => new NotFoundObjectResult(problemDetailsBuilder .WithTitle("The requested document could not be found") .Build()), ContentVersionOperationStatus.InvalidSkipTake => SkipTakeToPagingProblem(), - ContentVersionOperationStatus.RollBackFailed => BadRequest(problemDetailsBuilder + ContentVersionOperationStatus.RollBackFailed => new BadRequestObjectResult(problemDetailsBuilder .WithTitle("Rollback failed") - .WithDetail("An unspecified error occurred while rolling back the requested version. Please check the logs for additional information.")), - ContentVersionOperationStatus.RollBackCanceled => BadRequest(problemDetailsBuilder + .WithDetail( + "An unspecified error occurred while rolling back the requested version. Please check the logs for additional information.")), + ContentVersionOperationStatus.RollBackCanceled => new BadRequestObjectResult(problemDetailsBuilder .WithTitle("Request cancelled by notification") .WithDetail("The request to roll back was cancelled by a notification handler.") .Build()), - _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder + _ => new ObjectResult(problemDetailsBuilder .WithTitle("Unknown content version operation status.") - .Build()), + .Build()) { StatusCode = StatusCodes.Status500InternalServerError }, }); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/ByKeyElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/ByKeyElementController.cs new file mode 100644 index 000000000000..b4c5a0a6ae2d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/ByKeyElementController.cs @@ -0,0 +1,61 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; +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.Element; + +[ApiVersion("1.0")] +public class ByKeyElementController : ElementControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IElementService _elementService; + private readonly IElementPresentationFactory _elementPresentationFactory; + + public ByKeyElementController( + IAuthorizationService authorizationService, + IElementService elementService, + IElementPresentationFactory elementPresentationFactory) + { + _authorizationService = authorizationService; + _elementService = elementService; + _elementPresentationFactory = elementPresentationFactory; + } + + [HttpGet("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ElementResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ByKey(CancellationToken cancellationToken, Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementBrowse.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + IElement? element = _elementService.GetById(id); + if (element is null) + { + return ContentEditingOperationStatusResult(ContentEditingOperationStatus.NotFound); + } + + ContentScheduleCollection contentScheduleCollection = _elementService.GetContentScheduleByContentId(id); + + ElementResponseModel model = _elementPresentationFactory.CreateResponseModel(element, contentScheduleCollection); + return Ok(model); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/ConfigurationElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/ConfigurationElementController.cs new file mode 100644 index 000000000000..5b1c0d7360ae --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/ConfigurationElementController.cs @@ -0,0 +1,25 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class ConfigurationElementController : ElementControllerBase +{ + private readonly IConfigurationPresentationFactory _configurationPresentationFactory; + + public ConfigurationElementController(IConfigurationPresentationFactory configurationPresentationFactory) + => _configurationPresentationFactory = configurationPresentationFactory; + + [HttpGet("configuration")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ElementConfigurationResponseModel), StatusCodes.Status200OK)] + public Task Configuration(CancellationToken cancellationToken) + { + ElementConfigurationResponseModel responseModel = _configurationPresentationFactory.CreateElementConfigurationResponseModel(); + return Task.FromResult(Ok(responseModel)); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/CopyElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/CopyElementController.cs new file mode 100644 index 000000000000..f825d450650a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/CopyElementController.cs @@ -0,0 +1,72 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +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.Element; + +[ApiVersion("1.0")] +public class CopyElementController : ElementControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public CopyElementController( + IAuthorizationService authorizationService, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _authorizationService = authorizationService; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPost("{id:guid}/copy")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Copy(CancellationToken cancellationToken, Guid id, CopyElementRequestModel copyElementRequestModel) + { + // Check Copy permission on source element + AuthorizationResult sourceAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementCopy.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!sourceAuthorizationResult.Succeeded) + { + return Forbidden(); + } + + // Check Create permission on target (where we're copying to) + AuthorizationResult targetAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementNew.ActionLetter, copyElementRequestModel.Target?.Id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!targetAuthorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _elementEditingService.CopyAsync( + id, + copyElementRequestModel.Target?.Id, + CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? CreatedAtId(controller => nameof(controller.ByKey), result.Result!.Key) + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/CreateElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/CreateElementController.cs new file mode 100644 index 000000000000..35269aaeb56c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/CreateElementController.cs @@ -0,0 +1,50 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class CreateElementController : CreateElementControllerBase +{ + private readonly IElementEditingPresentationFactory _elementEditingPresentationFactory; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public CreateElementController( + IAuthorizationService authorizationService, + IElementEditingPresentationFactory elementEditingPresentationFactory, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService) + { + _elementEditingPresentationFactory = elementEditingPresentationFactory; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPost] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Create(CancellationToken cancellationToken, CreateElementRequestModel requestModel) + => await HandleRequest(requestModel, async () => + { + ElementCreateModel model = _elementEditingPresentationFactory.MapCreateModel(requestModel); + Attempt result = + await _elementEditingService.CreateAsync(model, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? CreatedAtId(controller => nameof(controller.ByKey), result.Result.Content!.Key) + : ContentEditingOperationStatusResult(result.Status); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/CreateElementControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/CreateElementControllerBase.cs new file mode 100644 index 000000000000..64ae453fb95e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/CreateElementControllerBase.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +public abstract class CreateElementControllerBase : ElementControllerBase +{ + private readonly IAuthorizationService _authorizationService; + + protected CreateElementControllerBase(IAuthorizationService authorizationService) + => _authorizationService = authorizationService; + + protected async Task HandleRequest(CreateElementRequestModel requestModel, Func> authorizedHandler) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementNew.ActionLetter, requestModel.Parent?.Id), + AuthorizationPolicies.ElementPermissionByResource); + + if (authorizationResult.Succeeded is false) + { + return Forbidden(); + } + + return await authorizedHandler(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/DeleteElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/DeleteElementController.cs new file mode 100644 index 000000000000..2259805f3abf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/DeleteElementController.cs @@ -0,0 +1,57 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +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.Element; + +[ApiVersion("1.0")] +public class DeleteElementController : ElementControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public DeleteElementController( + IAuthorizationService authorizationService, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _authorizationService = authorizationService; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpDelete("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Delete(CancellationToken cancellationToken, Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementDelete.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _elementEditingService.DeleteAsync(id, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/ElementControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/ElementControllerBase.cs new file mode 100644 index 000000000000..cdae9f8a3891 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/ElementControllerBase.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Controllers.Content; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[VersionedApiBackOfficeRoute(Constants.UdiEntityType.Element)] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Element))] +[Authorize(Policy = AuthorizationPolicies.TreeAccessElements)] +public class ElementControllerBase : ContentControllerBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ByKeyElementFolderController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ByKeyElementFolderController.cs new file mode 100644 index 000000000000..7b300da92a36 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ByKeyElementFolderController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Folder; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder; + +[ApiVersion("1.0")] +public class ByKeyElementFolderController : ElementFolderControllerBase +{ + private readonly IAuthorizationService _authorizationService; + + public ByKeyElementFolderController( + IAuthorizationService authorizationService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) => + _authorizationService = authorizationService; + + [HttpGet("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(FolderResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ByKey(CancellationToken cancellationToken, Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementBrowse.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + return await GetFolderAsync(id); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/CreateElementFolderController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/CreateElementFolderController.cs new file mode 100644 index 000000000000..d10ac7edc517 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/CreateElementFolderController.cs @@ -0,0 +1,48 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Folder; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder; + +[ApiVersion("1.0")] +public class CreateElementFolderController : ElementFolderControllerBase +{ + private readonly IAuthorizationService _authorizationService; + + public CreateElementFolderController( + IAuthorizationService authorizationService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) => + _authorizationService = authorizationService; + + [HttpPost] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Create(CancellationToken cancellationToken, CreateFolderRequestModel createFolderRequestModel) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementNew.ActionLetter, createFolderRequestModel.Parent?.Id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + return await CreateFolderAsync( + createFolderRequestModel, + controller => nameof(controller.ByKey)); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/DeleteElementFolderController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/DeleteElementFolderController.cs new file mode 100644 index 000000000000..0cf7947c4dcb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/DeleteElementFolderController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder; + +[ApiVersion("1.0")] +public class DeleteElementFolderController : ElementFolderControllerBase +{ + private readonly IAuthorizationService _authorizationService; + + public DeleteElementFolderController( + IAuthorizationService authorizationService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) => + _authorizationService = authorizationService; + + [HttpDelete("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Delete(CancellationToken cancellationToken, Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementDelete.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + return await DeleteFolderAsync(id); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ElementFolderControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ElementFolderControllerBase.cs new file mode 100644 index 000000000000..2fca34f0d425 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ElementFolderControllerBase.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder; + +[VersionedApiBackOfficeRoute($"{Constants.UdiEntityType.Element}/folder")] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Element))] +[Authorize(Policy = AuthorizationPolicies.TreeAccessElements)] +public abstract class ElementFolderControllerBase : FolderManagementControllerBase +{ + protected ElementFolderControllerBase( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) + { + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/Item/FolderItemControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/Item/FolderItemControllerBase.cs new file mode 100644 index 000000000000..b2ef8b7777b8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/Item/FolderItemControllerBase.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder.Item; + +[VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.Item}/{Constants.UdiEntityType.Element}/folder")] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Element))] +public class FolderItemControllerBase : ManagementApiControllerBase; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/Item/ItemFolderItemController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/Item/ItemFolderItemController.cs new file mode 100644 index 000000000000..831a45ee6e1f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/Item/ItemFolderItemController.cs @@ -0,0 +1,44 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Folder.Item; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder.Item; + +[ApiVersion("1.0")] +public class ItemFolderItemController : FolderItemControllerBase +{ + private readonly IEntityService _entityService; + private readonly IUmbracoMapper _umbracoMapper; + + public ItemFolderItemController( + IEntityService entityService, + IUmbracoMapper umbracoMapper) + { + _entityService = entityService; + _umbracoMapper = umbracoMapper; + } + + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public Task Item( + CancellationToken cancellationToken, + [FromQuery(Name = "id")] HashSet ids) + { + if (ids.Count is 0) + { + return Task.FromResult(Ok(Enumerable.Empty())); + } + + IEnumerable elements = _entityService + .GetAll([UmbracoObjectTypes.ElementContainer], ids.ToArray()); + + List responseModels = _umbracoMapper.MapEnumerable(elements); + return Task.FromResult(Ok(responseModels)); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/MoveElementFolderController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/MoveElementFolderController.cs new file mode 100644 index 000000000000..f4cbe49d6b64 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/MoveElementFolderController.cs @@ -0,0 +1,72 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Folder; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +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.Element.Folder; + +[ApiVersion("1.0")] +public class MoveElementFolderController : ElementFolderControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IElementContainerService _elementContainerService; + + public MoveElementFolderController( + IAuthorizationService authorizationService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) + { + _authorizationService = authorizationService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _elementContainerService = elementContainerService; + } + + [HttpPut("{id:guid}/move")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Move(CancellationToken cancellationToken, Guid id, MoveFolderRequestModel moveFolderRequestModel) + { + // Check Move permission on source folder + AuthorizationResult sourceAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementMove.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!sourceAuthorizationResult.Succeeded) + { + return Forbidden(); + } + + // Check Create permission on target (where we're moving to) + AuthorizationResult targetAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementNew.ActionLetter, moveFolderRequestModel.Target?.Id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!targetAuthorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _elementContainerService + .MoveAsync(id, moveFolderRequestModel.Target?.Id, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : OperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/MoveToRecycleBinElementFolderController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/MoveToRecycleBinElementFolderController.cs new file mode 100644 index 000000000000..cb4b9def244f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/MoveToRecycleBinElementFolderController.cs @@ -0,0 +1,59 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +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.Element.Folder; + +[ApiVersion("1.0")] +public class MoveToRecycleBinElementFolderController : ElementFolderControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IElementContainerService _elementContainerService; + + public MoveToRecycleBinElementFolderController( + IAuthorizationService authorizationService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) + { + _authorizationService = authorizationService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _elementContainerService = elementContainerService; + } + + [HttpPut("{id:guid}/move-to-recycle-bin")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Move(CancellationToken cancellationToken, Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementDelete.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _elementContainerService + .MoveToRecycleBinAsync(id, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : OperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/UpdateElementFolderController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/UpdateElementFolderController.cs new file mode 100644 index 000000000000..12cf62f75469 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/UpdateElementFolderController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Folder; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder; + +[ApiVersion("1.0")] +public class UpdateElementFolderController : ElementFolderControllerBase +{ + private readonly IAuthorizationService _authorizationService; + + public UpdateElementFolderController( + IAuthorizationService authorizationService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) => + _authorizationService = authorizationService; + + [HttpPut("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Update(CancellationToken cancellationToken, Guid id, UpdateFolderResponseModel updateFolderResponseModel) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementUpdate.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + return await UpdateFolderAsync(id, updateFolderResponseModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Item/ElementItemControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Item/ElementItemControllerBase.cs new file mode 100644 index 000000000000..4663ece56538 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Item/ElementItemControllerBase.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Item; + +[VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.Item}/{Constants.UdiEntityType.Element}")] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Element))] +public class ElementItemControllerBase : ManagementApiControllerBase; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Item/ItemElementItemController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Item/ItemElementItemController.cs new file mode 100644 index 000000000000..9967d8477b27 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Item/ItemElementItemController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element.Item; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Item; + +[ApiVersion("1.0")] +public class ItemElementItemController : ElementItemControllerBase +{ + private readonly IEntityService _entityService; + private readonly IElementPresentationFactory _elementPresentationFactory; + + public ItemElementItemController( + IEntityService entityService, + IElementPresentationFactory elementPresentationFactory) + { + _entityService = entityService; + _elementPresentationFactory = elementPresentationFactory; + } + + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public Task Item( + CancellationToken cancellationToken, + [FromQuery(Name = "id")] HashSet ids) + { + if (ids.Count is 0) + { + return Task.FromResult(Ok(Enumerable.Empty())); + } + + IEnumerable elements = _entityService + .GetAll(UmbracoObjectTypes.Element, ids.ToArray()) + .OfType(); + + IEnumerable responseModels = elements.Select(_elementPresentationFactory.CreateItemResponseModel); + return Task.FromResult(Ok(responseModels)); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/MoveElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/MoveElementController.cs new file mode 100644 index 000000000000..67bc7ef8536a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/MoveElementController.cs @@ -0,0 +1,71 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security; +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.Element; + +[ApiVersion("1.0")] +public class MoveElementController : ElementControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public MoveElementController( + IAuthorizationService authorizationService, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _authorizationService = authorizationService; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPut("{id:guid}/move")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Move(CancellationToken cancellationToken, Guid id, MoveElementRequestModel moveElementRequestModel) + { + // Check Move permission on source element + AuthorizationResult sourceAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementMove.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!sourceAuthorizationResult.Succeeded) + { + return Forbidden(); + } + + // Check Create permission on target (where we're moving to) + AuthorizationResult targetAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementNew.ActionLetter, moveElementRequestModel.Target?.Id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!targetAuthorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _elementEditingService.MoveAsync( + id, + moveElementRequestModel.Target?.Id, + CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/MoveToRecycleBinElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/MoveToRecycleBinElementController.cs new file mode 100644 index 000000000000..2ca754ce70e3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/MoveToRecycleBinElementController.cs @@ -0,0 +1,55 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security; +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.Element; + +[ApiVersion("1.0")] +public class MoveToRecycleBinElementController : ElementControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public MoveToRecycleBinElementController( + IAuthorizationService authorizationService, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _authorizationService = authorizationService; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPut("{id:guid}/move-to-recycle-bin")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task MoveToRecycleBin(CancellationToken cancellationToken, Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementDelete.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _elementEditingService.MoveToRecycleBinAsync(id, CurrentUserKey(_backOfficeSecurityAccessor)); + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/PublishElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/PublishElementController.cs new file mode 100644 index 000000000000..ead5823bdd4b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/PublishElementController.cs @@ -0,0 +1,81 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Security; +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.Element; + +[ApiVersion("1.0")] +public class PublishElementController : ElementControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IElementPublishingService _elementPublishingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IDocumentPresentationFactory _documentPresentationFactory; + + public PublishElementController( + IAuthorizationService authorizationService, + IElementPublishingService elementPublishingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IDocumentPresentationFactory documentPresentationFactory) + { + _authorizationService = authorizationService; + _elementPublishingService = elementPublishingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _documentPresentationFactory = documentPresentationFactory; + } + + [HttpPut("{id:guid}/publish")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Publish(CancellationToken cancellationToken, Guid id, PublishElementRequestModel requestModel) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys( + ActionElementPublish.ActionLetter, + id, + requestModel.PublishSchedules + .Where(x => x.Culture is not null) + .Select(x => x.Culture!)), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + // TODO ELEMENTS: IDocumentPresentationFactory carries the implementation of this mapping - it should probably be renamed + var tempModel = new PublishDocumentRequestModel { PublishSchedules = requestModel.PublishSchedules }; + Attempt, ContentPublishingOperationStatus> modelResult = _documentPresentationFactory.CreateCulturePublishScheduleModels(tempModel); + + if (modelResult.Success is false) + { + // TODO ELEMENTS: use refactored DocumentPublishingOperationStatusResult from DocumentControllerBase once it's ready + return BadRequest(); + } + + Attempt attempt = await _elementPublishingService.PublishAsync( + id, + modelResult.Result, + CurrentUserKey(_backOfficeSecurityAccessor)); + return attempt.Success + ? Ok() + // TODO ELEMENTS: use refactored DocumentPublishingOperationStatusResult from DocumentControllerBase once it's ready + : BadRequest(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/ChildrenElementRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/ChildrenElementRecycleBinController.cs new file mode 100644 index 000000000000..32aae7401662 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/ChildrenElementRecycleBinController.cs @@ -0,0 +1,30 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element.RecycleBin; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; + +[ApiVersion("1.0")] +public class ChildrenElementRecycleBinController : ElementRecycleBinControllerBase +{ + public ChildrenElementRecycleBinController( + IEntityService entityService, + IElementPresentationFactory elementPresentationFactory) + : base(entityService, elementPresentationFactory) + { + } + + [HttpGet("children")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> Children( + CancellationToken cancellationToken, + Guid parentId, + int skip = 0, + int take = 100) + => await GetChildren(parentId, skip, take); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/DeleteElementFolderRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/DeleteElementFolderRecycleBinController.cs new file mode 100644 index 000000000000..484c43097f25 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/DeleteElementFolderRecycleBinController.cs @@ -0,0 +1,61 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +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.Element.RecycleBin; + +[ApiVersion("1.0")] +public class DeleteElementFolderRecycleBinController : ElementRecycleBinControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IElementContainerService _elementContainerService; + + public DeleteElementFolderRecycleBinController( + IAuthorizationService authorizationService, + IEntityService entityService, + IElementPresentationFactory elementPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(entityService, elementPresentationFactory) + { + _authorizationService = authorizationService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _elementContainerService = elementContainerService; + } + + [HttpDelete("folder/{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Delete(CancellationToken cancellationToken, Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.RecycleBin(ActionElementDelete.ActionLetter), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _elementContainerService.DeleteFromRecycleBinAsync(id, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : OperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/DeleteElementRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/DeleteElementRecycleBinController.cs new file mode 100644 index 000000000000..a64d4cb1cc49 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/DeleteElementRecycleBinController.cs @@ -0,0 +1,61 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +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.Element.RecycleBin; + +[ApiVersion("1.0")] +public class DeleteElementRecycleBinController : ElementRecycleBinControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IElementEditingService _elementEditingService; + + public DeleteElementRecycleBinController( + IAuthorizationService authorizationService, + IEntityService entityService, + IElementPresentationFactory elementPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementEditingService elementEditingService) + : base(entityService, elementPresentationFactory) + { + _authorizationService = authorizationService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _elementEditingService = elementEditingService; + } + + [HttpDelete("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Delete(CancellationToken cancellationToken, Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.RecycleBin(ActionElementDelete.ActionLetter), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _elementEditingService.DeleteFromRecycleBinAsync(id, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/ElementRecycleBinControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/ElementRecycleBinControllerBase.cs new file mode 100644 index 000000000000..4d91bc68f620 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/ElementRecycleBinControllerBase.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Controllers.Element.Folder; +using Umbraco.Cms.Api.Management.Controllers.RecycleBin; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Filters; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.ViewModels.Element.RecycleBin; +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.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; + +[VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.RecycleBin}/{Constants.UdiEntityType.Element}")] +[RequireElementTreeRootAccess] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Element))] +[Authorize(Policy = AuthorizationPolicies.TreeAccessElements)] +public class ElementRecycleBinControllerBase : RecycleBinControllerBase +{ + private readonly IEntityService _entityService; + private readonly IElementPresentationFactory _elementPresentationFactory; + + public ElementRecycleBinControllerBase( IEntityService entityService, IElementPresentationFactory elementPresentationFactory) + : base(entityService) + { + _entityService = entityService; + _elementPresentationFactory = elementPresentationFactory; + } + + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Element; + + protected override Guid RecycleBinRootKey => Constants.System.RecycleBinElementKey; + + protected override ElementRecycleBinItemResponseModel MapRecycleBinViewModel(Guid? parentId, IEntitySlim entity) + { + ElementRecycleBinItemResponseModel responseModel = base.MapRecycleBinViewModel(parentId, entity); + + responseModel.Name = entity.Name ?? string.Empty; + + if (entity is IElementEntitySlim elementEntitySlim) + { + responseModel.Variants = _elementPresentationFactory.CreateVariantsItemResponseModels(elementEntitySlim); + responseModel.DocumentType = _elementPresentationFactory.CreateDocumentTypeReferenceResponseModel(elementEntitySlim); + responseModel.IsFolder = false; + } + else + { + responseModel.Variants = []; + responseModel.IsFolder = true; + } + + return responseModel; + } + + protected override IEntitySlim[] GetPagedRootEntities(int skip, int take, out long totalItems) + => GetPagedChildEntities(RecycleBinRootKey, skip, take, out totalItems); + + protected override IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems) + { + IEntitySlim[] rootEntities = _entityService + .GetPagedChildren( + parentKey, + parentObjectTypes: [UmbracoObjectTypes.ElementContainer], + childObjectTypes: [UmbracoObjectTypes.ElementContainer, ItemObjectType], + skip: skip, + take: take, + trashed: true, + out totalItems) + .ToArray(); + + return rootEntities; + } + + protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) => + _entityService + .GetTrashedSiblings( + target, + objectTypes: [UmbracoObjectTypes.ElementContainer, ItemObjectType], + before, + after, + out totalBefore, + out totalAfter, + ordering: Ordering.By(nameof(Infrastructure.Persistence.Dtos.NodeDto.Text))) + .ToArray(); + + protected IActionResult OperationStatusResult(EntityContainerOperationStatus status) + => ElementFolderControllerBase.OperationStatusResult(status); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/EmptyElementRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/EmptyElementRecycleBinController.cs new file mode 100644 index 000000000000..ba83e1e17948 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/EmptyElementRecycleBinController.cs @@ -0,0 +1,58 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security; +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.Element.RecycleBin; + +[ApiVersion("1.0")] +public class EmptyElementRecycleBinController : ElementRecycleBinControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IElementContainerService _elementContainerService; + + public EmptyElementRecycleBinController( + IAuthorizationService authorizationService, + IEntityService entityService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService, + IElementPresentationFactory elementPresentationFactory) + : base(entityService, elementPresentationFactory) + { + _authorizationService = authorizationService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _elementContainerService = elementContainerService; + } + + [HttpDelete] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task EmptyRecycleBin(CancellationToken cancellationToken) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.RecycleBin(ActionElementDelete.ActionLetter), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _elementContainerService.EmptyRecycleBinAsync(CurrentUserKey(_backOfficeSecurityAccessor)); + return result.Success + ? Ok() + : OperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/RootElementRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/RootElementRecycleBinController.cs new file mode 100644 index 000000000000..f7285276a8c5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/RootElementRecycleBinController.cs @@ -0,0 +1,29 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element.RecycleBin; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; + +[ApiVersion("1.0")] +public class RootElementRecycleBinController : ElementRecycleBinControllerBase +{ + public RootElementRecycleBinController( + IEntityService entityService, + IElementPresentationFactory elementPresentationFactory) + : base(entityService, elementPresentationFactory) + { + } + + [HttpGet("root")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> Root( + CancellationToken cancellationToken, + int skip = 0, + int take = 100) + => await GetRoot(skip, take); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/SiblingsElementRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/SiblingsElementRecycleBinController.cs new file mode 100644 index 000000000000..144c8e6c7b9a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/RecycleBin/SiblingsElementRecycleBinController.cs @@ -0,0 +1,26 @@ +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.Element.RecycleBin; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; + +[ApiVersion("1.0")] +public class SiblingsElementRecycleBinController : ElementRecycleBinControllerBase +{ + public SiblingsElementRecycleBinController( + IEntityService entityService, + IElementPresentationFactory elementPresentationFactory) + : base(entityService, elementPresentationFactory) + { + } + + [HttpGet("siblings")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + => await GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/AncestorsElementTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/AncestorsElementTreeController.cs new file mode 100644 index 000000000000..9899644762ab --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/AncestorsElementTreeController.cs @@ -0,0 +1,34 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Api.Management.Services.Flags; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Tree; + +[ApiVersion("1.0")] +public class AncestorsElementTreeController : ElementTreeControllerBase +{ + public AncestorsElementTreeController( + IEntityService entityService, + FlagProviderCollection flagProviders, + IUserStartNodeEntitiesService userStartNodeEntitiesService, + IDataTypeService dataTypeService, + AppCaches appCaches, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementPresentationFactory elementPresentationFactory) + : base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory) + { + } + + [HttpGet("ancestors")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> Ancestors(CancellationToken cancellationToken, Guid descendantId) + => await GetAncestors(descendantId); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ChildrenElementTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ChildrenElementTreeController.cs new file mode 100644 index 000000000000..cc37255bdc36 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ChildrenElementTreeController.cs @@ -0,0 +1,38 @@ +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.Services.Flags; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Tree; + +[ApiVersion("1.0")] +public class ChildrenElementTreeController : ElementTreeControllerBase +{ + public ChildrenElementTreeController( + IEntityService entityService, + FlagProviderCollection flagProviders, + IUserStartNodeEntitiesService userStartNodeEntitiesService, + IDataTypeService dataTypeService, + AppCaches appCaches, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementPresentationFactory elementPresentationFactory) + : base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory) + { + } + + [HttpGet("children")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> Children(CancellationToken cancellationToken, Guid parentId, int skip = 0, int take = 100, bool foldersOnly = false) + { + RenderFoldersOnly(foldersOnly); + return await GetChildren(parentId, skip, take); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ElementTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ElementTreeControllerBase.cs new file mode 100644 index 000000000000..69493e109adf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ElementTreeControllerBase.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +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.Services.Flags; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Tree; + +[VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.Tree}/{Constants.UdiEntityType.Element}")] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Element))] +[Authorize(Policy = AuthorizationPolicies.SectionAccessForElementTree)] +public class ElementTreeControllerBase : UserStartNodeFolderTreeControllerBase +{ + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IElementPresentationFactory _elementPresentationFactory; + + public ElementTreeControllerBase( + IEntityService entityService, + FlagProviderCollection flagProviders, + IUserStartNodeEntitiesService userStartNodeEntitiesService, + IDataTypeService dataTypeService, + AppCaches appCaches, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementPresentationFactory elementPresentationFactory) + : base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService) + { + _appCaches = appCaches; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _elementPresentationFactory = elementPresentationFactory; + } + + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Element; + + protected override UmbracoObjectTypes FolderObjectType => UmbracoObjectTypes.ElementContainer; + + protected override int[] GetUserStartNodeIds() + => _backOfficeSecurityAccessor + .BackOfficeSecurity? + .CurrentUser? + .CalculateElementStartNodeIds(EntityService, _appCaches) + ?? []; + + protected override string[] GetUserStartNodePaths() + => _backOfficeSecurityAccessor + .BackOfficeSecurity? + .CurrentUser? + .GetElementStartNodePaths(EntityService, _appCaches) + ?? []; + + protected override ElementTreeItemResponseModel MapTreeItemViewModel(Guid? parentKey, IEntitySlim entity) + { + ElementTreeItemResponseModel responseModel = base.MapTreeItemViewModel(parentKey, entity); + + if (entity is IElementEntitySlim elementEntitySlim) + { + responseModel.HasChildren = false; + responseModel.CreateDate = elementEntitySlim.CreateDate; + responseModel.DocumentType = _elementPresentationFactory.CreateDocumentTypeReferenceResponseModel(elementEntitySlim); + responseModel.Variants = _elementPresentationFactory.CreateVariantsItemResponseModels(elementEntitySlim); + } + + return responseModel; + } + + protected override ElementTreeItemResponseModel MapTreeItemViewModelAsNoAccess(Guid? parentKey, IEntitySlim entity) + { + ElementTreeItemResponseModel viewModel = MapTreeItemViewModel(parentKey, entity); + viewModel.NoAccess = true; + return viewModel; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/RootElementTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/RootElementTreeController.cs new file mode 100644 index 000000000000..792243e02a9e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/RootElementTreeController.cs @@ -0,0 +1,42 @@ +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.Services.Flags; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Tree; + +[ApiVersion("1.0")] +public class RootElementTreeController : ElementTreeControllerBase +{ + public RootElementTreeController( + IEntityService entityService, + FlagProviderCollection flagProviders, + IUserStartNodeEntitiesService userStartNodeEntitiesService, + IDataTypeService dataTypeService, + AppCaches appCaches, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementPresentationFactory elementPresentationFactory) + : base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory) + { + } + + [HttpGet("root")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> Root( + CancellationToken cancellationToken, + int skip = 0, + int take = 100, + bool foldersOnly = false) + { + RenderFoldersOnly(foldersOnly); + return await GetRoot(skip, take); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/SiblingsElementTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/SiblingsElementTreeController.cs new file mode 100644 index 000000000000..70d1e6e5a400 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/SiblingsElementTreeController.cs @@ -0,0 +1,43 @@ +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.Services.Flags; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Tree; + +[ApiVersion("1.0")] +public class SiblingsElementTreeController : ElementTreeControllerBase +{ + public SiblingsElementTreeController( + IEntityService entityService, + FlagProviderCollection flagProviders, + IUserStartNodeEntitiesService userStartNodeEntitiesService, + IDataTypeService dataTypeService, + AppCaches appCaches, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementPresentationFactory elementPresentationFactory) + : base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory) + { + } + + [HttpGet("siblings")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings( + CancellationToken cancellationToken, + Guid target, + int before, + int after, + bool foldersOnly = false) + { + RenderFoldersOnly(foldersOnly); + return await GetSiblings(target, before, after); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/UnpublishElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/UnpublishElementController.cs new file mode 100644 index 000000000000..9681f2438197 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/UnpublishElementController.cs @@ -0,0 +1,63 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security; +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.Element; + +[ApiVersion("1.0")] +public class UnpublishElementController : ElementControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IElementPublishingService _elementPublishingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public UnpublishElementController( + IAuthorizationService authorizationService, + IElementPublishingService elementPublishingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _authorizationService = authorizationService; + _elementPublishingService = elementPublishingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPut("{id:guid}/unpublish")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Unpublish(CancellationToken cancellationToken, Guid id, UnpublishElementRequestModel requestModel) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys( + ActionElementUnpublish.ActionLetter, + id, + requestModel.Cultures ?? Enumerable.Empty()), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt attempt = await _elementPublishingService.UnpublishAsync( + id, + requestModel.Cultures, + CurrentUserKey(_backOfficeSecurityAccessor)); + return attempt.Success + ? Ok() + // TODO ELEMENTS: use refactored DocumentPublishingOperationStatusResult from DocumentControllerBase once it's ready + : BadRequest(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/UpdateElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/UpdateElementController.cs new file mode 100644 index 000000000000..3e3c5e1330ed --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/UpdateElementController.cs @@ -0,0 +1,50 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class UpdateElementController : UpdateElementControllerBase +{ + private readonly IElementEditingPresentationFactory _elementEditingPresentationFactory; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public UpdateElementController( + IAuthorizationService authorizationService, + IElementEditingPresentationFactory elementEditingPresentationFactory, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService) + { + _elementEditingPresentationFactory = elementEditingPresentationFactory; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPut("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Update(CancellationToken cancellationToken, Guid id, UpdateElementRequestModel requestModel) + => await HandleRequest(id, requestModel, async () => + { + ElementUpdateModel model = _elementEditingPresentationFactory.MapUpdateModel(requestModel); + Attempt result = + await _elementEditingService.UpdateAsync(id, model, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/UpdateElementControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/UpdateElementControllerBase.cs new file mode 100644 index 000000000000..6037c46a6b10 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/UpdateElementControllerBase.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +public abstract class UpdateElementControllerBase : ElementControllerBase +{ + private readonly IAuthorizationService _authorizationService; + + protected UpdateElementControllerBase(IAuthorizationService authorizationService) + => _authorizationService = authorizationService; + + protected async Task HandleRequest(Guid id, UpdateElementRequestModel requestModel, Func> authorizedHandler) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementUpdate.ActionLetter, id), + AuthorizationPolicies.ElementPermissionByResource); + + if (authorizationResult.Succeeded is false) + { + return Forbidden(); + } + + return await authorizedHandler(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateCreateElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateCreateElementController.cs new file mode 100644 index 000000000000..d9beea2dfa40 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateCreateElementController.cs @@ -0,0 +1,50 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class ValidateCreateElementController : CreateElementControllerBase +{ + private readonly IElementEditingPresentationFactory _elementEditingPresentationFactory; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public ValidateCreateElementController( + IAuthorizationService authorizationService, + IElementEditingPresentationFactory elementEditingPresentationFactory, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService) + { + _elementEditingPresentationFactory = elementEditingPresentationFactory; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPost("validate")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Validate(CancellationToken cancellationToken, CreateElementRequestModel requestModel) + => await HandleRequest(requestModel, async () => + { + ElementCreateModel model = _elementEditingPresentationFactory.MapCreateModel(requestModel); + Attempt result = + await _elementEditingService.ValidateCreateAsync(model, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateUpdateElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateUpdateElementController.cs new file mode 100644 index 000000000000..b49498091753 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateUpdateElementController.cs @@ -0,0 +1,50 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class ValidateUpdateElementController : UpdateElementControllerBase +{ + private readonly IElementEditingPresentationFactory _elementEditingPresentationFactory; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public ValidateUpdateElementController( + IAuthorizationService authorizationService, + IElementEditingPresentationFactory elementEditingPresentationFactory, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService) + { + _elementEditingPresentationFactory = elementEditingPresentationFactory; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPut("{id:guid}/validate")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Validate(CancellationToken cancellationToken, Guid id, ValidateUpdateElementRequestModel requestModel) + => await HandleRequest(id, requestModel, async () => + { + ValidateElementUpdateModel model = _elementEditingPresentationFactory.MapValidateUpdateModel(requestModel); + Attempt result = + await _elementEditingService.ValidateUpdateAsync(id, model, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/AllElementVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/AllElementVersionController.cs new file mode 100644 index 000000000000..53aa5a970fbf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/AllElementVersionController.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; +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.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.ElementVersion; + +[ApiVersion("1.0")] +public class AllElementVersionController : ElementVersionControllerBase +{ + private readonly IElementVersionService _elementVersionService; + private readonly IElementVersionPresentationFactory _elementVersionPresentationFactory; + + public AllElementVersionController( + IElementVersionService elementVersionService, + IElementVersionPresentationFactory elementVersionPresentationFactory) + { + _elementVersionService = elementVersionService; + _elementVersionPresentationFactory = elementVersionPresentationFactory; + } + + [MapToApiVersion("1.0")] + [HttpGet] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task All( + CancellationToken cancellationToken, + [Required] Guid elementId, + string? culture, + int skip = 0, + int take = 100) + { + Attempt?, ContentVersionOperationStatus> attempt = + await _elementVersionService.GetPagedContentVersionsAsync(elementId, culture, skip, take); + + if (attempt.Success is false) + { + return MapFailure(attempt.Status); + } + + var pagedViewModel = new PagedViewModel + { + Total = attempt.Result!.Total, + Items = await _elementVersionPresentationFactory.CreateMultipleAsync(attempt.Result!.Items), + }; + + return Ok(pagedViewModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ByKeyElementVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ByKeyElementVersionController.cs new file mode 100644 index 000000000000..5c80b5f3c2f6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ByKeyElementVersionController.cs @@ -0,0 +1,41 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.ElementVersion; + +[ApiVersion("1.0")] +public class ByKeyElementVersionController : ElementVersionControllerBase +{ + private readonly IElementVersionService _elementVersionService; + private readonly IUmbracoMapper _umbracoMapper; + + public ByKeyElementVersionController( + IElementVersionService elementVersionService, + IUmbracoMapper umbracoMapper) + { + _elementVersionService = elementVersionService; + _umbracoMapper = umbracoMapper; + } + + [MapToApiVersion("1.0")] + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(ElementVersionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task ByKey(CancellationToken cancellationToken, Guid id) + { + Attempt attempt = + await _elementVersionService.GetAsync(id); + + return attempt.Success + ? Ok(_umbracoMapper.Map(attempt.Result)) + : MapFailure(attempt.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ElementVersionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ElementVersionControllerBase.cs new file mode 100644 index 000000000000..e0979734308d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ElementVersionControllerBase.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Controllers.DocumentVersion; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.Controllers.ElementVersion; + +[VersionedApiBackOfficeRoute($"{Constants.UdiEntityType.Element}-version")] +[ApiExplorerSettings(GroupName = $"{nameof(Constants.UdiEntityType.Element)} Version")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessElements)] +public abstract class ElementVersionControllerBase : ManagementApiControllerBase +{ + protected IActionResult MapFailure(ContentVersionOperationStatus status) + => DocumentVersionControllerBase.MapFailure(status); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/RollbackElementVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/RollbackElementVersionController.cs new file mode 100644 index 000000000000..1059bd8f493e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/RollbackElementVersionController.cs @@ -0,0 +1,65 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +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.ElementVersion; + +[ApiVersion("1.0")] +public class RollbackElementVersionController : ElementVersionControllerBase +{ + private readonly IElementVersionService _elementVersionService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IAuthorizationService _authorizationService; + + public RollbackElementVersionController( + IElementVersionService elementVersionService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IAuthorizationService authorizationService) + { + _elementVersionService = elementVersionService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _authorizationService = authorizationService; + } + + [MapToApiVersion("1.0")] + [HttpPost("{id:guid}/rollback")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Rollback(CancellationToken cancellationToken, Guid id, string? culture) + { + Attempt getContentAttempt = await _elementVersionService.GetAsync(id); + if (getContentAttempt.Success is false || getContentAttempt.Result is null) + { + return MapFailure(getContentAttempt.Status); + } + + IElement element = getContentAttempt.Result; + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ElementPermissionResource.WithKeys(ActionElementRollback.ActionLetter, element.Key), + AuthorizationPolicies.ElementPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt rollBackAttempt = + await _elementVersionService.RollBackAsync(id, culture, CurrentUserKey(_backOfficeSecurityAccessor)); + + return rollBackAttempt.Success + ? Ok() + : MapFailure(rollBackAttempt.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/UpdatePreventCleanupElementVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/UpdatePreventCleanupElementVersionController.cs new file mode 100644 index 000000000000..f19bdbec99ce --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/UpdatePreventCleanupElementVersionController.cs @@ -0,0 +1,39 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.ElementVersion; + +[ApiVersion("1.0")] +public class UpdatePreventCleanupElementVersionController : ElementVersionControllerBase +{ + private readonly IElementVersionService _elementVersionService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public UpdatePreventCleanupElementVersionController( + IElementVersionService elementVersionService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _elementVersionService = elementVersionService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [MapToApiVersion("1.0")] + [HttpPut("{id:guid}/prevent-cleanup")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Set(CancellationToken cancellationToken, Guid id, bool preventCleanup) + { + Attempt attempt = + await _elementVersionService.SetPreventCleanupAsync(id, preventCleanup, CurrentUserKey(_backOfficeSecurityAccessor)); + + return attempt.Success + ? Ok() + : MapFailure(attempt.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/FolderManagementControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/FolderManagementControllerBase.cs index 8f1957d550e6..52a60039b9be 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/FolderManagementControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/FolderManagementControllerBase.cs @@ -39,7 +39,8 @@ protected async Task GetFolderAsync(Guid key) return Ok(new FolderResponseModel { Name = container.Name!, - Id = container.Key + Id = container.Key, + IsTrashed = container.Trashed }); } @@ -78,33 +79,33 @@ protected async Task DeleteFolderAsync(Guid key) : OperationStatusResult(result.Status); } - protected IActionResult OperationStatusResult(EntityContainerOperationStatus status) + internal static IActionResult OperationStatusResult(EntityContainerOperationStatus status) => OperationStatusResult(status, problemDetailsBuilder => status switch { - EntityContainerOperationStatus.NotFound => NotFound(problemDetailsBuilder + EntityContainerOperationStatus.NotFound => new NotFoundObjectResult(problemDetailsBuilder .WithTitle("The folder could not be found") .Build()), - EntityContainerOperationStatus.ParentNotFound => NotFound(problemDetailsBuilder + EntityContainerOperationStatus.ParentNotFound => new NotFoundObjectResult(problemDetailsBuilder .WithTitle("The parent folder could not be found") .Build()), - EntityContainerOperationStatus.DuplicateName => BadRequest(problemDetailsBuilder + EntityContainerOperationStatus.DuplicateName => new BadRequestObjectResult(problemDetailsBuilder .WithTitle("The name is already used") .WithDetail("The folder name must be unique on this parent.") .Build()), - EntityContainerOperationStatus.DuplicateKey => BadRequest(problemDetailsBuilder + EntityContainerOperationStatus.DuplicateKey => new BadRequestObjectResult(problemDetailsBuilder .WithTitle("The id is already used") .WithDetail("The folder id must be unique.") .Build()), - EntityContainerOperationStatus.NotEmpty => BadRequest(problemDetailsBuilder + EntityContainerOperationStatus.NotEmpty => new BadRequestObjectResult(problemDetailsBuilder .WithTitle("The folder is not empty") .WithDetail("The folder must be empty to perform this action.") .Build()), - EntityContainerOperationStatus.CancelledByNotification => BadRequest(problemDetailsBuilder + EntityContainerOperationStatus.CancelledByNotification => new BadRequestObjectResult(problemDetailsBuilder .WithTitle("Cancelled by notification") .WithDetail("A notification handler prevented the folder operation.") .Build()), - _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder + _ => new ObjectResult(problemDetailsBuilder .WithTitle("Unknown folder operation status.") - .Build()), + .Build()) { StatusCode = StatusCodes.Status500InternalServerError }, }); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs index c5ff9382c499..b7e8cba99b9f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs @@ -66,8 +66,8 @@ protected static IActionResult OperationStatusResult(TEnum status, Func

result(new ProblemDetailsBuilder().WithOperationStatus(status)); - protected BadRequestObjectResult SkipTakeToPagingProblem() => - BadRequest(new ProblemDetails + protected static BadRequestObjectResult SkipTakeToPagingProblem() => + new(new ProblemDetails { Title = "Invalid skip/take", Detail = "Skip must be a multiple of take - i.e. skip = 10, take = 5", diff --git a/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs index d386a2cf1ecb..aae6ceb1b77c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs @@ -124,7 +124,7 @@ protected IActionResult MapRecycleBinQueryAttemptFailure(RecycleBinQueryResultTy .Build()), }); - private IEntitySlim[] GetPagedRootEntities(int skip, int take, out long totalItems) + protected virtual IEntitySlim[] GetPagedRootEntities(int skip, int take, out long totalItems) { IEntitySlim[] rootEntities = _entityService .GetPagedTrashedChildren(RecycleBinRootKey, ItemObjectType, skip, take, out totalItems) @@ -133,7 +133,7 @@ private IEntitySlim[] GetPagedRootEntities(int skip, int take, out long totalIte return rootEntities; } - private IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems) + protected virtual IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems) { IEntitySlim? parent = _entityService.Get(parentKey, ItemObjectType); if (parent == null || parent.Trashed == false) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeFolderTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeFolderTreeControllerBase.cs new file mode 100644 index 000000000000..4075b8ad9713 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeFolderTreeControllerBase.cs @@ -0,0 +1,181 @@ +using Umbraco.Cms.Api.Management.Models.Entities; +using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Api.Management.Services.Flags; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Tree; + +///

+/// Base class for folder tree controllers that support user start node filtering. +/// +/// The type of tree item response model. +/// +/// This base class combines folder tree support from +/// with user start node filtering, similar to . +/// Users without root access will only see items within their configured start nodes, +/// with ancestor items marked as "no access" for navigation purposes. +/// +public abstract class UserStartNodeFolderTreeControllerBase : FolderTreeControllerBase + where TItem : FolderTreeItemResponseModel, new() +{ + private readonly IUserStartNodeEntitiesService _userStartNodeEntitiesService; + private readonly IDataTypeService _dataTypeService; + + private Dictionary _accessMap = new(); + private Guid? _dataTypeKey; + + /// + /// Initializes a new instance of the class. + /// + /// The entity service. + /// The flag provider collection. + /// The user start node entities service. + /// The data type service. + protected UserStartNodeFolderTreeControllerBase( + IEntityService entityService, + FlagProviderCollection flagProviders, + IUserStartNodeEntitiesService userStartNodeEntitiesService, + IDataTypeService dataTypeService) + : base(entityService, flagProviders) + { + _userStartNodeEntitiesService = userStartNodeEntitiesService; + _dataTypeService = dataTypeService; + } + + private UmbracoObjectTypes[] TreeObjectTypes => [FolderObjectType, ItemObjectType]; + + /// + /// Gets the calculated start node IDs for the current user. + /// + /// An array of start node IDs. + protected abstract int[] GetUserStartNodeIds(); + + /// + /// Gets the calculated start node paths for the current user. + /// + /// An array of start node paths. + protected abstract string[] GetUserStartNodePaths(); + + /// + /// Configures the controller to ignore user start nodes for a specific data type. + /// + /// The data type key, or null to disable. + protected void IgnoreUserStartNodesForDataType(Guid? dataTypeKey) => _dataTypeKey = dataTypeKey; + + /// + protected override IEntitySlim[] GetPagedRootEntities(int skip, int take, out long totalItems) + => UserHasRootAccess() || IgnoreUserStartNodes() + ? base.GetPagedRootEntities(skip, take, out totalItems) + : CalculateAccessMap(() => _userStartNodeEntitiesService.RootUserAccessEntities(TreeObjectTypes, UserStartNodeIds), out totalItems); + + /// + protected override IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems) + { + if (UserHasRootAccess() || IgnoreUserStartNodes()) + { + return base.GetPagedChildEntities(parentKey, skip, take, out totalItems); + } + + IEnumerable userAccessEntities = _userStartNodeEntitiesService.ChildUserAccessEntities( + TreeObjectTypes, + UserStartNodePaths, + parentKey, + skip, + take, + ItemOrdering, + out totalItems); + + return CalculateAccessMap(() => userAccessEntities, out _); + } + + /// + 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, out totalBefore, out totalAfter); + } + + IEnumerable userAccessEntities = _userStartNodeEntitiesService.SiblingUserAccessEntities( + TreeObjectTypes, + UserStartNodePaths, + target, + before, + after, + ItemOrdering, + out totalBefore, + out totalAfter); + + return CalculateAccessMap(() => userAccessEntities, out _); + } + + /// + protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities) + { + if (UserHasRootAccess() || IgnoreUserStartNodes()) + { + return base.MapTreeItemViewModels(parentKey, entities); + } + + // for users with no root access, only add items for the entities contained within the calculated access map. + // the access map may contain entities that the user does not have direct access to, but need still to see, + // because it has descendants that the user *does* have access to. these entities are added as "no access" items. + TItem[] treeItemViewModels = entities.Select(entity => + { + if (_accessMap.TryGetValue(entity.Key, out var hasAccess) is false) + { + // entity is not a part of the calculated access map + return null; + } + + // direct access => return a regular item + // no direct access => return a "no access" item + return hasAccess + ? MapTreeItemViewModel(parentKey, entity) + : MapTreeItemViewModelAsNoAccess(parentKey, entity); + }) + .WhereNotNull() + .ToArray(); + + return treeItemViewModels; + } + + /// + /// Maps an entity to a tree item view model marked as "no access". + /// + /// The parent key. + /// The entity to map. + /// The mapped tree item view model. + /// + /// Subclasses should override this to set the appropriate "no access" flag on the view model. + /// + protected virtual TItem MapTreeItemViewModelAsNoAccess(Guid? parentKey, IEntitySlim entity) + => MapTreeItemViewModel(parentKey, entity); + + private int[] UserStartNodeIds => field ??= GetUserStartNodeIds(); + + private string[] UserStartNodePaths => field ??= GetUserStartNodePaths(); + + private bool UserHasRootAccess() => UserStartNodeIds.Contains(Constants.System.Root); + + private bool IgnoreUserStartNodes() + => _dataTypeKey.HasValue + && _dataTypeService.IsDataTypeIgnoringUserStartNodes(_dataTypeKey.Value); + + private IEntitySlim[] CalculateAccessMap(Func> getUserAccessEntities, out long totalItems) + { + UserAccessEntity[] userAccessEntities = getUserAccessEntities().ToArray(); + + _accessMap = userAccessEntities.ToDictionary(uae => uae.Entity.Key, uae => uae.HasAccess); + + IEntitySlim[] entities = userAccessEntities.Select(uae => uae.Entity).ToArray(); + totalItems = entities.Length; + + return entities; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetElementPermissionsCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetElementPermissionsCurrentUserController.cs new file mode 100644 index 000000000000..1daa46c71375 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetElementPermissionsCurrentUserController.cs @@ -0,0 +1,50 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.User.Current; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.User.Current; + +[ApiVersion("1.0")] +public class GetElementPermissionsCurrentUserController : CurrentUserControllerBase +{ + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IUserService _userService; + private readonly IUmbracoMapper _mapper; + + public GetElementPermissionsCurrentUserController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUserService userService, + IUmbracoMapper mapper) + { + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _userService = userService; + _mapper = mapper; + } + + [MapToApiVersion("1.0")] + [HttpGet("permissions/element")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetPermissions( + CancellationToken cancellationToken, + [FromQuery(Name = "id")] HashSet ids) + { + Attempt, UserOperationStatus> permissionsAttempt = await _userService.GetElementPermissionsAsync(CurrentUserKey(_backOfficeSecurityAccessor), ids); + + if (permissionsAttempt.Success is false) + { + return UserOperationStatusResult(permissionsAttempt.Status); + } + + List viewModels = _mapper.MapEnumerable(permissionsAttempt.Result); + + return Ok(new UserPermissionsResponseModel { Permissions = viewModels }); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/UserGroupControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/UserGroupControllerBase.cs index 0112df812797..e717bb93d397 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/UserGroupControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/UserGroupControllerBase.cs @@ -53,6 +53,10 @@ protected IActionResult UserGroupOperationStatusResult(UserGroupOperationStatus .WithTitle("Media start node key not found") .WithDetail("The assigned media start node does not exists.") .Build()), + UserGroupOperationStatus.ElementStartNodeKeyNotFound => NotFound(problemDetailsBuilder + .WithTitle("Element start node key not found") + .WithDetail("The assigned element start node does not exist.") + .Build()), UserGroupOperationStatus.DocumentPermissionKeyNotFound => NotFound(new ProblemDetailsBuilder() .WithTitle("Document permission key not found") .WithDetail("An assigned document permission does not reference an existing document.") diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthPolicyBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthPolicyBuilderExtensions.cs index 842c7f39358d..fcc20a3196ef 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthPolicyBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthPolicyBuilderExtensions.cs @@ -4,6 +4,7 @@ using Umbraco.Cms.Api.Management.Security.Authorization.Content; using Umbraco.Cms.Api.Management.Security.Authorization.DenyLocalLogin; using Umbraco.Cms.Api.Management.Security.Authorization.Dictionary; +using Umbraco.Cms.Api.Management.Security.Authorization.Element; using Umbraco.Cms.Api.Management.Security.Authorization.Media; using Umbraco.Cms.Api.Management.Security.Authorization.User; using Umbraco.Cms.Api.Management.Security.Authorization.UserGroup; @@ -24,6 +25,7 @@ internal static IUmbracoBuilder AddAuthorizationPolicies(this IUmbracoBuilder bu builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -65,7 +67,8 @@ void AddAllowedApplicationsPolicy(string policyName, params string[] allowedClai Constants.Applications.Users, Constants.Applications.Settings, Constants.Applications.Packages, - Constants.Applications.Members); + Constants.Applications.Members, + Constants.Applications.Library); AddAllowedApplicationsPolicy( AuthorizationPolicies.SectionAccessForMediaTree, Constants.Applications.Content, @@ -73,25 +76,40 @@ void AddAllowedApplicationsPolicy(string policyName, params string[] allowedClai Constants.Applications.Users, Constants.Applications.Settings, Constants.Applications.Packages, - Constants.Applications.Members); + Constants.Applications.Members, + Constants.Applications.Library); AddAllowedApplicationsPolicy( AuthorizationPolicies.SectionAccessForMemberTree, Constants.Applications.Content, Constants.Applications.Media, - Constants.Applications.Members); + Constants.Applications.Members, + Constants.Applications.Library); + AddAllowedApplicationsPolicy( + AuthorizationPolicies.SectionAccessForElementTree, + Constants.Applications.Content, + Constants.Applications.Media, + Constants.Applications.Users, + Constants.Applications.Settings, + Constants.Applications.Packages, + Constants.Applications.Members, + Constants.Applications.Library); AddAllowedApplicationsPolicy(AuthorizationPolicies.SectionAccessMedia, Constants.Applications.Media); AddAllowedApplicationsPolicy(AuthorizationPolicies.SectionAccessMembers, Constants.Applications.Members); AddAllowedApplicationsPolicy(AuthorizationPolicies.SectionAccessPackages, Constants.Applications.Packages); AddAllowedApplicationsPolicy(AuthorizationPolicies.SectionAccessSettings, Constants.Applications.Settings); AddAllowedApplicationsPolicy(AuthorizationPolicies.SectionAccessUsers, Constants.Applications.Users); + AddAllowedApplicationsPolicy(AuthorizationPolicies.SectionAccessLibrary, Constants.Applications.Library); AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDataTypes, Constants.Applications.Settings); AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDictionary, Constants.Applications.Translation); AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDictionaryOrTemplates, Constants.Applications.Translation, Constants.Applications.Settings); AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocuments, Constants.Applications.Content); + AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessElements, Constants.Applications.Library); AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes, Constants.Applications.Content, Constants.Applications.Settings); + AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocumentsOrElementsOrDocumentTypes, Constants.Applications.Content, Constants.Applications.Library, Constants.Applications.Settings); AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocumentOrMediaOrContentTypes, Constants.Applications.Content, Constants.Applications.Settings, Constants.Applications.Media); AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocumentsOrMediaOrMembersOrContentTypes, Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Members, Constants.Applications.Settings); + AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocumentsOrElementsOrMediaOrMembersOrContentTypes, Constants.Applications.Content, Constants.Applications.Library, Constants.Applications.Media, Constants.Applications.Members, Constants.Applications.Settings); AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocumentTypes, Constants.Applications.Settings); AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessLanguages, Constants.Applications.Settings); AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessMediaTypes, Constants.Applications.Settings); @@ -126,6 +144,12 @@ void AddAllowedApplicationsPolicy(string policyName, params string[] allowedClai policy.Requirements.Add(new DictionaryPermissionRequirement()); }); + options.AddPolicy(AuthorizationPolicies.ElementPermissionByResource, policy => + { + policy.AuthenticationSchemes.Add(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); + policy.Requirements.Add(new ElementPermissionRequirement()); + }); + options.AddPolicy(AuthorizationPolicies.MediaPermissionByResource, policy => { policy.AuthenticationSchemes.Add(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/ElementBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/ElementBuilderExtensions.cs new file mode 100644 index 000000000000..7b6afd2cb0b0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/ElementBuilderExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Mapping.Element; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Mapping; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +internal static class ElementBuilderExtensions +{ + internal static IUmbracoBuilder AddElements(this IUmbracoBuilder builder) + { + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + + builder.WithCollectionBuilder() + .Add() + .Add(); + + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs index a99e6de55303..b19dc464b8c8 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs @@ -36,6 +36,7 @@ public static IUmbracoBuilder AddUmbracoManagementApi(this IUmbracoBuilder build .AddConfigurationFactories() .AddDocuments() .AddDocumentTypes() + .AddElements() .AddMedia() .AddMediaTypes() .AddMemberGroups() diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs index 525215157392..fa3fce383fcb 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs @@ -19,6 +19,9 @@ internal static IUmbracoBuilder AddUserGroups(this IUmbracoBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + return builder; } } diff --git a/src/Umbraco.Cms.Api.Management/Factories/ConfigurationPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/ConfigurationPresentationFactory.cs index 31c10ae4cade..5255637ec567 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/ConfigurationPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/ConfigurationPresentationFactory.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Element; using Umbraco.Cms.Api.Management.ViewModels.Media; using Umbraco.Cms.Api.Management.ViewModels.MediaType; using Umbraco.Cms.Api.Management.ViewModels.Member; @@ -72,4 +73,13 @@ public MediaTypeConfigurationResponseModel CreateMediaTypeConfigurationResponseM { ReservedFieldNames = _reservedFieldNamesService.GetMediaReservedFieldNames(), }; + + public ElementConfigurationResponseModel CreateElementConfigurationResponseModel() => + new() + { + DisableDeleteWhenReferenced = _contentSettings.DisableDeleteWhenReferenced, + DisableUnpublishWhenReferenced = _contentSettings.DisableUnpublishWhenReferenced, + AllowEditInvariantFromNonDefault = _contentSettings.AllowEditInvariantFromNonDefault, + AllowNonExistingSegmentsCreation = _segmentSettings.AllowCreation, + }; } diff --git a/src/Umbraco.Cms.Api.Management/Factories/ContentVersionPresentationFactoryBase.cs b/src/Umbraco.Cms.Api.Management/Factories/ContentVersionPresentationFactoryBase.cs new file mode 100644 index 000000000000..d732dc3520ab --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/ContentVersionPresentationFactoryBase.cs @@ -0,0 +1,65 @@ +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Factories; + +internal abstract class ContentVersionPresentationFactoryBase +{ + private readonly IEntityService _entityService; + private readonly IUserIdKeyResolver _userIdKeyResolver; + + protected abstract UmbracoObjectTypes ItemObjectType { get; } + + protected ContentVersionPresentationFactoryBase(IEntityService entityService, IUserIdKeyResolver userIdKeyResolver) + { + _entityService = entityService; + _userIdKeyResolver = userIdKeyResolver; + } + + protected abstract TVersionItemResponseModel VersionItemResponseModelFactory( + Guid versionId, + ReferenceByIdModel item, + ReferenceByIdModel documentType, + ReferenceByIdModel user, + DateTimeOffset versionDate, + bool isCurrentPublishedVersion, + bool isCurrentDraftVersion, + bool preventCleanup); + + public async Task CreateAsync(ContentVersionMeta contentVersion) + { + ReferenceByIdModel userReference = contentVersion.UserId switch + { + Constants.Security.UnknownUserId => new ReferenceByIdModel(), + _ => new ReferenceByIdModel(await _userIdKeyResolver.GetAsync(contentVersion.UserId)), + }; + + return VersionItemResponseModelFactory( + contentVersion.VersionId.ToGuid(), // this is a magic guid since versions do not have keys in the DB + new ReferenceByIdModel(_entityService.GetKey(contentVersion.ContentId, ItemObjectType).Result), + new ReferenceByIdModel( + _entityService.GetKey(contentVersion.ContentTypeId, UmbracoObjectTypes.DocumentType) + .Result), + userReference, + new DateTimeOffset(contentVersion.VersionDate), + contentVersion.CurrentPublishedVersion, + contentVersion.CurrentDraftVersion, + contentVersion.PreventCleanup); + } + + public async Task> CreateMultipleAsync(IEnumerable contentVersions) + => await CreateMultipleImplAsync(contentVersions).ToArrayAsync(); + + private async IAsyncEnumerable CreateMultipleImplAsync(IEnumerable contentVersions) + { + foreach (ContentVersionMeta contentVersion in contentVersions) + { + yield return await CreateAsync(contentVersion); + } + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs index 144be70a4c29..7a981c2a5152 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs @@ -1,54 +1,27 @@ using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Document; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Factories; -internal sealed class DocumentVersionPresentationFactory : IDocumentVersionPresentationFactory +internal sealed class DocumentVersionPresentationFactory : ContentVersionPresentationFactoryBase, IDocumentVersionPresentationFactory { - private readonly IEntityService _entityService; - private readonly IUserIdKeyResolver _userIdKeyResolver; - - public DocumentVersionPresentationFactory( - IEntityService entityService, - IUserIdKeyResolver userIdKeyResolver) + public DocumentVersionPresentationFactory(IEntityService entityService, IUserIdKeyResolver userIdKeyResolver) + : base(entityService, userIdKeyResolver) { - _entityService = entityService; - _userIdKeyResolver = userIdKeyResolver; } - public async Task CreateAsync(ContentVersionMeta contentVersion) - { - ReferenceByIdModel userReference = contentVersion.UserId switch - { - Constants.Security.UnknownUserId => new ReferenceByIdModel(), - _ => new ReferenceByIdModel(await _userIdKeyResolver.GetAsync(contentVersion.UserId)), - }; - - return new DocumentVersionItemResponseModel( - contentVersion.VersionId.ToGuid(), // this is a magic guid since versions do not have keys in the DB - new ReferenceByIdModel(_entityService.GetKey(contentVersion.ContentId, UmbracoObjectTypes.Document).Result), - new ReferenceByIdModel( - _entityService.GetKey(contentVersion.ContentTypeId, UmbracoObjectTypes.DocumentType) - .Result), - userReference, - new DateTimeOffset(contentVersion.VersionDate), - contentVersion.CurrentPublishedVersion, - contentVersion.CurrentDraftVersion, - contentVersion.PreventCleanup); - } + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Document; - public async Task> CreateMultipleAsync(IEnumerable contentVersions) - => await CreateMultipleImplAsync(contentVersions).ToArrayAsync(); - - private async IAsyncEnumerable CreateMultipleImplAsync(IEnumerable contentVersions) - { - foreach (ContentVersionMeta contentVersion in contentVersions) - { - yield return await CreateAsync(contentVersion); - } - } + protected override DocumentVersionItemResponseModel VersionItemResponseModelFactory( + Guid versionId, + ReferenceByIdModel item, + ReferenceByIdModel documentType, + ReferenceByIdModel user, + DateTimeOffset versionDate, + bool isCurrentPublishedVersion, + bool isCurrentDraftVersion, + bool preventCleanup) + => new(versionId, item, documentType, user, versionDate, isCurrentPublishedVersion, isCurrentDraftVersion, preventCleanup); } diff --git a/src/Umbraco.Cms.Api.Management/Factories/ElementEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/ElementEditingPresentationFactory.cs new file mode 100644 index 000000000000..e1bf2ced86e8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/ElementEditingPresentationFactory.cs @@ -0,0 +1,28 @@ +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.Factories; + +internal sealed class ElementEditingPresentationFactory : ContentEditingPresentationFactory, IElementEditingPresentationFactory +{ + public ElementCreateModel MapCreateModel(CreateElementRequestModel requestModel) + { + ElementCreateModel model = MapContentEditingModel(requestModel); + model.Key = requestModel.Id; + model.ContentTypeKey = requestModel.DocumentType.Id; + model.ParentKey = requestModel.Parent?.Id; + + return model; + } + + public ElementUpdateModel MapUpdateModel(UpdateElementRequestModel requestModel) + => MapContentEditingModel(requestModel); + + public ValidateElementUpdateModel MapValidateUpdateModel(ValidateUpdateElementRequestModel requestModel) + { + ValidateElementUpdateModel model = MapContentEditingModel(requestModel); + model.Cultures = requestModel.Cultures; + + return model; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/ElementPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/ElementPresentationFactory.cs new file mode 100644 index 000000000000..7378ab512f1e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/ElementPresentationFactory.cs @@ -0,0 +1,83 @@ +using Umbraco.Cms.Api.Management.Mapping.Content; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Api.Management.ViewModels.Element.Item; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Factories; + +// TODO ELEMENTS: lots of code here was duplicated from DocumentPresentationFactory - abstract and refactor +public class ElementPresentationFactory : IElementPresentationFactory +{ + private readonly IUmbracoMapper _umbracoMapper; + private readonly IIdKeyMap _idKeyMap; + + public ElementPresentationFactory(IUmbracoMapper umbracoMapper, IIdKeyMap idKeyMap) + { + _umbracoMapper = umbracoMapper; + _idKeyMap = idKeyMap; + } + + public ElementResponseModel CreateResponseModel(IElement element, ContentScheduleCollection schedule) + { + ElementResponseModel responseModel = _umbracoMapper.Map(element)!; + _umbracoMapper.Map(schedule, responseModel); + + return responseModel; + } + + public ElementItemResponseModel CreateItemResponseModel(IElementEntitySlim entity) + { + Attempt parentKeyAttempt = _idKeyMap.GetKeyForId(entity.ParentId, UmbracoObjectTypes.ElementContainer); + + var responseModel = new ElementItemResponseModel + { + Id = entity.Key, + Parent = parentKeyAttempt.Success ? new ReferenceByIdModel { Id = parentKeyAttempt.Result } : null, + HasChildren = entity.HasChildren, + }; + + responseModel.DocumentType = _umbracoMapper.Map(entity)!; + + responseModel.Variants = CreateVariantsItemResponseModels(entity); + + return responseModel; + } + + public IEnumerable CreateVariantsItemResponseModels(IElementEntitySlim entity) + { + if (entity.Variations.VariesByCulture() is false) + { + var model = new ElementVariantItemResponseModel() + { + Name = entity.Name ?? string.Empty, + State = DocumentVariantStateHelper.GetState(entity, null), + Culture = null, + }; + + yield return model; + yield break; + } + + foreach (KeyValuePair cultureNamePair in entity.CultureNames) + { + var model = new ElementVariantItemResponseModel() + { + Name = cultureNamePair.Value, + Culture = cultureNamePair.Key, + State = DocumentVariantStateHelper.GetState(entity, cultureNamePair.Key) + }; + + yield return model; + } + } + + public DocumentTypeReferenceResponseModel CreateDocumentTypeReferenceResponseModel(IElementEntitySlim entity) + => _umbracoMapper.Map(entity)!; +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/ElementVersionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/ElementVersionPresentationFactory.cs new file mode 100644 index 000000000000..c94b3d2f1c9b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/ElementVersionPresentationFactory.cs @@ -0,0 +1,30 @@ +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Factories; + +internal sealed class ElementVersionPresentationFactory : ContentVersionPresentationFactoryBase, IElementVersionPresentationFactory +{ + public ElementVersionPresentationFactory(IEntityService entityService, IUserIdKeyResolver userIdKeyResolver) + : base(entityService, userIdKeyResolver) + { + } + + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Element; + + protected override ElementVersionItemResponseModel VersionItemResponseModelFactory( + Guid versionId, + ReferenceByIdModel item, + ReferenceByIdModel documentType, + ReferenceByIdModel user, + DateTimeOffset versionDate, + bool isCurrentPublishedVersion, + bool isCurrentDraftVersion, + bool preventCleanup) + => new(versionId, item, documentType, user, versionDate, isCurrentPublishedVersion, isCurrentDraftVersion, preventCleanup); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IConfigurationPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IConfigurationPresentationFactory.cs index 14870f0357b3..644db7593b7a 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IConfigurationPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IConfigurationPresentationFactory.cs @@ -1,5 +1,6 @@ using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Element; using Umbraco.Cms.Api.Management.ViewModels.Media; using Umbraco.Cms.Api.Management.ViewModels.MediaType; using Umbraco.Cms.Api.Management.ViewModels.Member; @@ -24,4 +25,6 @@ MemberTypeConfigurationResponseModel CreateMemberTypeConfigurationResponseModel( MediaTypeConfigurationResponseModel CreateMediaTypeConfigurationResponseModel() => throw new NotImplementedException(); + + ElementConfigurationResponseModel CreateElementConfigurationResponseModel(); } diff --git a/src/Umbraco.Cms.Api.Management/Factories/IElementEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IElementEditingPresentationFactory.cs new file mode 100644 index 000000000000..61463d619908 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IElementEditingPresentationFactory.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IElementEditingPresentationFactory +{ + ElementCreateModel MapCreateModel(CreateElementRequestModel requestModel); + + ElementUpdateModel MapUpdateModel(UpdateElementRequestModel requestModel); + + ValidateElementUpdateModel MapValidateUpdateModel(ValidateUpdateElementRequestModel requestModel); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IElementPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IElementPresentationFactory.cs new file mode 100644 index 000000000000..943baf922a99 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IElementPresentationFactory.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Api.Management.ViewModels.Element.Item; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IElementPresentationFactory +{ + ElementResponseModel CreateResponseModel(IElement element, ContentScheduleCollection schedule); + + ElementItemResponseModel CreateItemResponseModel(IElementEntitySlim entity); + + IEnumerable CreateVariantsItemResponseModels(IElementEntitySlim entity); + + DocumentTypeReferenceResponseModel CreateDocumentTypeReferenceResponseModel(IElementEntitySlim entity); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IElementVersionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IElementVersionPresentationFactory.cs new file mode 100644 index 000000000000..102483c2f322 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IElementVersionPresentationFactory.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IElementVersionPresentationFactory +{ + Task CreateAsync(ContentVersionMeta contentVersion); + + Task> CreateMultipleAsync( + IEnumerable contentVersions); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/UserGroupPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/UserGroupPresentationFactory.cs index 502cd1a98efc..80bd517b793a 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/UserGroupPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/UserGroupPresentationFactory.cs @@ -42,6 +42,8 @@ public async Task CreateAsync(IUserGroup userGroup) var contentRootAccess = contentStartNodeKey is null && userGroup.StartContentId == Constants.System.Root; Guid? mediaStartNodeKey = GetKeyFromId(userGroup.StartMediaId, UmbracoObjectTypes.Media); var mediaRootAccess = mediaStartNodeKey is null && userGroup.StartMediaId == Constants.System.Root; + Guid? elementStartNodeKey = GetKeyFromId(userGroup.StartElementId, UmbracoObjectTypes.ElementContainer); + var elementRootAccess = elementStartNodeKey is null && userGroup.StartElementId == Constants.System.Root; Attempt, UserGroupOperationStatus> languageIsoCodesMappingAttempt = await MapLanguageIdsToIsoCodeAsync(userGroup.AllowedLanguages); @@ -60,6 +62,8 @@ public async Task CreateAsync(IUserGroup userGroup) DocumentRootAccess = contentRootAccess, MediaStartNode = ReferenceByIdModel.ReferenceOrNull(mediaStartNodeKey), MediaRootAccess = mediaRootAccess, + ElementStartNode = ReferenceByIdModel.ReferenceOrNull(elementStartNodeKey), + ElementRootAccess = elementRootAccess, Icon = userGroup.Icon, Languages = languageIsoCodesMappingAttempt.Result, HasAccessToAllLanguages = userGroup.HasAccessToAllLanguages, @@ -77,6 +81,7 @@ public async Task CreateAsync(IReadOnlyUserGroup userGro // TODO figure out how to reuse code from Task CreateAsync(IUserGroup userGroup) instead of copying Guid? contentStartNodeKey = GetKeyFromId(userGroup.StartContentId, UmbracoObjectTypes.Document); Guid? mediaStartNodeKey = GetKeyFromId(userGroup.StartMediaId, UmbracoObjectTypes.Media); + Guid? elementStartNodeKey = GetKeyFromId(userGroup.StartElementId, UmbracoObjectTypes.ElementContainer); Attempt, UserGroupOperationStatus> languageIsoCodesMappingAttempt = await MapLanguageIdsToIsoCodeAsync(userGroup.AllowedLanguages); if (languageIsoCodesMappingAttempt.Success is false) @@ -92,6 +97,7 @@ public async Task CreateAsync(IReadOnlyUserGroup userGro Alias = userGroup.Alias, DocumentStartNode = ReferenceByIdModel.ReferenceOrNull(contentStartNodeKey), MediaStartNode = ReferenceByIdModel.ReferenceOrNull(mediaStartNodeKey), + ElementStartNode = ReferenceByIdModel.ReferenceOrNull(elementStartNodeKey), Icon = userGroup.Icon, Languages = languageIsoCodesMappingAttempt.Result, HasAccessToAllLanguages = userGroup.HasAccessToAllLanguages, @@ -278,6 +284,26 @@ private Attempt AssignStartNodesToUserGroup(UserGroupB target.StartMediaId = null; } + if (source.ElementStartNode is not null) + { + var elementId = GetIdFromKey(source.ElementStartNode.Id, UmbracoObjectTypes.ElementContainer); + + if (elementId is null) + { + return Attempt.Fail(UserGroupOperationStatus.ElementStartNodeKeyNotFound); + } + + target.StartElementId = elementId; + } + else if (source.ElementRootAccess) + { + target.StartElementId = Constants.System.Root; + } + else + { + target.StartElementId = null; + } + return Attempt.Succeed(UserGroupOperationStatus.Success); } diff --git a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs index bf5e085cfa51..651506b38d8f 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs @@ -89,6 +89,8 @@ public UserResponseModel CreateResponseModel(IUser user) HasDocumentRootAccess = HasRootAccess(user.StartContentIds), MediaStartNodeIds = GetKeysFromIds(user.StartMediaIds, UmbracoObjectTypes.Media), HasMediaRootAccess = HasRootAccess(user.StartMediaIds), + ElementStartNodeIds = GetKeysFromIds(user.StartElementIds, UmbracoObjectTypes.ElementContainer), + HasElementRootAccess = HasRootAccess(user.StartElementIds), FailedLoginAttempts = user.FailedPasswordAttempts, LastLoginDate = user.LastLoginDate, LastLockoutDate = user.LastLockoutDate, @@ -198,6 +200,8 @@ public Task CreateUpdateModelAsync(Guid existingUserKey, Update HasContentRootAccess = updateModel.HasDocumentRootAccess, MediaStartNodeKeys = updateModel.MediaStartNodeIds.Select(x => x.Id).ToHashSet(), HasMediaRootAccess = updateModel.HasMediaRootAccess, + ElementStartNodeKeys = updateModel.ElementStartNodeIds.Select(x => x.Id).ToHashSet(), + HasElementRootAccess = updateModel.HasElementRootAccess, UserGroupKeys = updateModel.UserGroupIds.Select(x => x.Id).ToHashSet() }; @@ -214,6 +218,8 @@ public async Task CreateCurrentUserResponseModelAsync( ISet mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media); var contentStartNodeIds = user.CalculateContentStartNodeIds(_entityService, _appCaches); ISet documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document); + var elementStartNodeIds = user.CalculateElementStartNodeIds(_entityService, _appCaches); + ISet elementStartNodeKeys = GetKeysFromIds(elementStartNodeIds, UmbracoObjectTypes.ElementContainer); HashSet permissions = GetAggregatedGranularPermissions(user, presentationGroups); var fallbackPermissions = presentationGroups.SelectMany(x => x.FallbackPermissions).ToHashSet(); @@ -235,6 +241,8 @@ public async Task CreateCurrentUserResponseModelAsync( HasMediaRootAccess = HasRootAccess(mediaStartNodeIds), DocumentStartNodeIds = documentStartNodeKeys, HasDocumentRootAccess = HasRootAccess(contentStartNodeIds), + ElementStartNodeIds = elementStartNodeKeys, + HasElementRootAccess = HasRootAccess(elementStartNodeIds), Permissions = permissions, FallbackPermissions = fallbackPermissions, HasAccessToAllLanguages = hasAccessToAllLanguages, @@ -303,6 +311,8 @@ public Task CreateCalculatedUserStartNode ISet mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media); var contentStartNodeIds = user.CalculateContentStartNodeIds(_entityService, _appCaches); ISet documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document); + var elementStartNodeIds = user.CalculateElementStartNodeIds(_entityService, _appCaches); + ISet elementStartNodeKeys = GetKeysFromIds(elementStartNodeIds, UmbracoObjectTypes.ElementContainer); return Task.FromResult(new CalculatedUserStartNodesResponseModel() { @@ -311,6 +321,8 @@ public Task CreateCalculatedUserStartNode HasMediaRootAccess = HasRootAccess(mediaStartNodeIds), DocumentStartNodeIds = documentStartNodeKeys, HasDocumentRootAccess = HasRootAccess(contentStartNodeIds), + ElementStartNodeIds = elementStartNodeKeys, + HasElementRootAccess = HasRootAccess(elementStartNodeIds), }); } diff --git a/src/Umbraco.Cms.Api.Management/Filters/RequireElementTreeRootAccessAttribute.cs b/src/Umbraco.Cms.Api.Management/Filters/RequireElementTreeRootAccessAttribute.cs new file mode 100644 index 000000000000..92a9b03e60d9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Filters/RequireElementTreeRootAccessAttribute.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Filters; + +public class RequireElementTreeRootAccessAttribute : RequireTreeRootAccessAttribute +{ + protected override int[] GetUserStartNodeIds(IUser user, ActionExecutingContext context) + { + AppCaches appCaches = context.HttpContext.RequestServices.GetRequiredService(); + IEntityService entityService = context.HttpContext.RequestServices.GetRequiredService(); + + return user.CalculateElementStartNodeIds(entityService, appCaches) ?? Array.Empty(); + } +} \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs index 06be1b91ff07..46daf47f5a7b 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; @@ -108,4 +109,33 @@ protected IEnumerable MapVariantViewModels(TContent source, V })) .ToArray(); } + + protected void MapContentScheduleCollection(ContentScheduleCollection source, TContentResponseModel target, MapperContext context) + where TContentResponseModel : ContentResponseModelBase + where TPublishableVariantResponseModelBase : PublishableVariantResponseModelBase, TVariantViewModel + { + foreach (ContentSchedule schedule in source.FullSchedule) + { + TPublishableVariantResponseModelBase? variant = target.Variants + .FirstOrDefault(v => + v.Culture == schedule.Culture || + (IsInvariant(v.Culture) && IsInvariant(schedule.Culture))); + if (variant is null) + { + continue; + } + + switch (schedule.Action) + { + case ContentScheduleAction.Release: + variant.ScheduledPublishDate = new DateTimeOffset(schedule.Date, TimeSpan.Zero); + break; + case ContentScheduleAction.Expire: + variant.ScheduledUnpublishDate = new DateTimeOffset(schedule.Date, TimeSpan.Zero); + break; + } + } + } + + private static bool IsInvariant(string? culture) => culture.IsNullOrWhiteSpace() || culture == Core.Constants.System.InvariantCulture; } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Content/DocumentVariantStateHelper.cs b/src/Umbraco.Cms.Api.Management/Mapping/Content/DocumentVariantStateHelper.cs index f565b9cdf027..bf9474ce20f9 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Content/DocumentVariantStateHelper.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Content/DocumentVariantStateHelper.cs @@ -4,9 +4,10 @@ namespace Umbraco.Cms.Api.Management.Mapping.Content; +// TODO ELEMENTS: rename this to VariantStateHelper or ContentVariantStateHelper (depending on the new name for DocumentVariantState) internal static class DocumentVariantStateHelper { - internal static DocumentVariantState GetState(IContent content, string? culture) + internal static DocumentVariantState GetState(IPublishableContentBase content, string? culture) => GetState( content, culture, @@ -28,6 +29,17 @@ internal static DocumentVariantState GetState(IDocumentEntitySlim content, strin content.EditedCultures, content.PublishedCultures); + internal static DocumentVariantState GetState(IElementEntitySlim element, string? culture) + => GetState( + element, + culture, + element.Edited, + element.Published, + element.Trashed, + element.CultureNames.Keys, + element.EditedCultures, + element.PublishedCultures); + private static DocumentVariantState GetState(IEntity entity, string? culture, bool edited, bool published, bool trashed, IEnumerable availableCultures, IEnumerable editedCultures, IEnumerable publishedCultures) { if (entity.Id <= 0 || (culture is not null && availableCultures.Contains(culture) is false)) diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs index 6fe2fbff5517..847443665bb1 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs @@ -133,29 +133,5 @@ private void Map(IContent source, DocumentBlueprintResponseModel target, MapperC } private void Map(ContentScheduleCollection source, DocumentResponseModel target, MapperContext context) - { - foreach (ContentSchedule schedule in source.FullSchedule) - { - DocumentVariantResponseModel? variant = target.Variants - .FirstOrDefault(v => - v.Culture == schedule.Culture || - (IsInvariant(v.Culture) && IsInvariant(schedule.Culture))); - if (variant is null) - { - continue; - } - - switch (schedule.Action) - { - case ContentScheduleAction.Release: - variant.ScheduledPublishDate = new DateTimeOffset(schedule.Date, TimeSpan.Zero); - break; - case ContentScheduleAction.Expire: - variant.ScheduledUnpublishDate = new DateTimeOffset(schedule.Date, TimeSpan.Zero); - break; - } - } - } - - private static bool IsInvariant(string? culture) => culture.IsNullOrWhiteSpace() || culture == Core.Constants.System.InvariantCulture; + => MapContentScheduleCollection(source, target, context); } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs index 0012d244a774..f3ef85240b57 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs @@ -34,6 +34,7 @@ public void DefineMaps(IUmbracoMapper mapper) mapper.Define((_, _) => new DocumentVersionResponseModel(), Map); } + // Umbraco.Code.MapAll -Flags private void Map(IContent source, DocumentVersionResponseModel target, MapperContext context) { target.Id = source.VersionId.ToGuid(); // this is a magic guid since versions do not have Guids in the DB diff --git a/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs index d8765c2461e5..812eb21ad2f6 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs @@ -19,6 +19,7 @@ public void DefineMaps(IUmbracoMapper mapper) mapper.Define((_, _) => new DocumentTypeCollectionReferenceResponseModel(), Map); mapper.Define((_, _) => new DocumentTypeReferenceResponseModel(), Map); mapper.Define((_, _) => new DocumentTypeReferenceResponseModel(), Map); + mapper.Define((_, _) => new DocumentTypeReferenceResponseModel(), Map); mapper.Define((_, _) => new DocumentTypeBlueprintItemResponseModel(), Map); } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementMapDefinition.cs new file mode 100644 index 000000000000..ab0b15aa22c1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementMapDefinition.cs @@ -0,0 +1,43 @@ +using Umbraco.Cms.Api.Management.Mapping.Content; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; + +namespace Umbraco.Cms.Api.Management.Mapping.Element; + +public class ElementMapDefinition : ContentMapDefinition, IMapDefinition +{ + public ElementMapDefinition(PropertyEditorCollection propertyEditorCollection) + : base(propertyEditorCollection) + { + } + + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define((_, _) => new ElementResponseModel(), Map); + mapper.Define(Map); + } + + // Umbraco.Code.MapAll -Flags + private void Map(IElement source, ElementResponseModel target, MapperContext context) + { + target.Id = source.Key; + target.DocumentType = context.Map(source.ContentType)!; + target.Values = MapValueViewModels(source.Properties); + target.Variants = MapVariantViewModels( + source, + (culture, _, documentVariantViewModel) => + { + documentVariantViewModel.State = DocumentVariantStateHelper.GetState(source, culture); + documentVariantViewModel.PublishDate = culture == null + ? source.PublishDate + : source.GetPublishDate(culture); + }); + target.IsTrashed = source.Trashed; + } + + private void Map(ContentScheduleCollection source, ElementResponseModel target, MapperContext context) + => MapContentScheduleCollection(source, target, context); +} diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementVersionMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementVersionMapDefinition.cs new file mode 100644 index 000000000000..65d8747e4fc2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementVersionMapDefinition.cs @@ -0,0 +1,39 @@ +using Umbraco.Cms.Api.Management.Mapping.Content; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Mapping.Element; + +public class ElementVersionMapDefinition : ContentMapDefinition, IMapDefinition +{ + public ElementVersionMapDefinition(PropertyEditorCollection propertyEditorCollection, IDataValueEditorFactory dataValueEditorFactory) + : base(propertyEditorCollection, dataValueEditorFactory) + { + } + + public void DefineMaps(IUmbracoMapper mapper) + => mapper.Define((_, _) => new ElementVersionResponseModel(), Map); + + // Umbraco.Code.MapAll -Flags + private void Map(IElement source, ElementVersionResponseModel target, MapperContext context) + { + target.Id = source.VersionId.ToGuid(); // this is a magic guid since versions do not have Guids in the DB + target.Element = new ReferenceByIdModel(source.Key); + target.DocumentType = context.Map(source.ContentType)!; + target.Values = MapValueViewModels(source.Properties); + target.Variants = MapVariantViewModels( + source, + (culture, _, documentVariantViewModel) => + { + documentVariantViewModel.State = DocumentVariantStateHelper.GetState(source, culture); + documentVariantViewModel.PublishDate = culture == null + ? source.PublishDate + : source.GetPublishDate(culture); + }); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs index 49fcdd0ffd26..f241fccda8f7 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs @@ -1,13 +1,13 @@ using Umbraco.Cms.Api.Management.ViewModels.DataType.Item; using Umbraco.Cms.Api.Management.ViewModels.Dictionary.Item; using Umbraco.Cms.Api.Management.ViewModels.DocumentType.Item; +using Umbraco.Cms.Api.Management.ViewModels.Folder.Item; using Umbraco.Cms.Api.Management.ViewModels.Language.Item; using Umbraco.Cms.Api.Management.ViewModels.MediaType.Item; using Umbraco.Cms.Api.Management.ViewModels.MemberGroup.Item; using Umbraco.Cms.Api.Management.ViewModels.MemberType.Item; using Umbraco.Cms.Api.Management.ViewModels.RelationType.Item; using Umbraco.Cms.Api.Management.ViewModels.Template.Item; -using Umbraco.Cms.Api.Management.ViewModels.User.Item; using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Item; using Umbraco.Cms.Api.Management.ViewModels.Webhook.Item; using Umbraco.Cms.Core.Mapping; @@ -33,6 +33,7 @@ public void DefineMaps(IUmbracoMapper mapper) mapper.Define((_, _) => new RelationTypeItemResponseModel(), Map); mapper.Define((_, _) => new UserGroupItemResponseModel(), Map); mapper.Define((_, _) => new WebhookItemResponseModel(), Map); + mapper.Define((_, _) => new FolderItemResponseModel(), Map); } // Umbraco.Code.MapAll @@ -127,4 +128,11 @@ private static void Map(IWebhook source, WebhookItemResponseModel target, Mapper target.Events = string.Join(",", source.Events); target.Types = string.Join(",", source.ContentTypeKeys); } + + // Umbraco.Code.MapAll -Flags + private static void Map(IEntitySlim source, FolderItemResponseModel target, MapperContext context) + { + target.Name = source.Name ?? string.Empty; + target.Id = source.Key; + } } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Permissions/ElementPermissionMapper.cs b/src/Umbraco.Cms.Api.Management/Mapping/Permissions/ElementPermissionMapper.cs new file mode 100644 index 000000000000..9fe43a9cf1d5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/Permissions/ElementPermissionMapper.cs @@ -0,0 +1,131 @@ +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Models.Membership.Permissions; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Mappers; + +namespace Umbraco.Cms.Api.Management.Mapping.Permissions; + +/// +/// Implements for element permissions. +/// +/// +/// This mapping maps all the way from management api to database in one file intentionally, so it is very clear what it takes, if we wanna add permissions to media or other types in the future. +/// +public class ElementPermissionMapper : IPermissionPresentationMapper, IPermissionMapper +{ + private readonly Lazy _entityService; + private readonly Lazy _userService; + + /// + /// Initializes a new instance of the class. + /// + public ElementPermissionMapper(Lazy entityService, Lazy userService) + { + _entityService = entityService; + _userService = userService; + } + + /// + public string Context => ElementGranularPermission.ContextType; + + /// + public Type PresentationModelToHandle => typeof(ElementPermissionPresentationModel); + + /// + public IGranularPermission MapFromDto(UserGroup2GranularPermissionDto dto) => + new ElementGranularPermission + { + Key = dto.UniqueId!.Value, + Permission = dto.Permission, + }; + + /// + public IEnumerable MapManyAsync(IEnumerable granularPermissions) + { + IEnumerable> keyGroups = granularPermissions.GroupBy(x => x.Key); + foreach (IGrouping keyGroup in keyGroups) + { + var verbs = keyGroup.Select(x => x.Permission).ToHashSet(); + if (keyGroup.Key.HasValue) + { + yield return new ElementPermissionPresentationModel + { + Element = new ReferenceByIdModel(keyGroup.Key.Value), + Verbs = verbs, + }; + } + } + } + + /// + public IEnumerable MapToGranularPermissions(IPermissionPresentationModel permissionViewModel) + { + if (permissionViewModel is not ElementPermissionPresentationModel elementPermissionPresentationModel) + { + yield break; + } + + if (elementPermissionPresentationModel.Verbs.Any() is false + || (elementPermissionPresentationModel.Verbs.Count == 1 + && elementPermissionPresentationModel.Verbs.Contains(string.Empty))) + { + yield return new ElementGranularPermission + { + Key = elementPermissionPresentationModel.Element.Id, + Permission = string.Empty, + }; + yield break; + } + + foreach (var verb in elementPermissionPresentationModel.Verbs) + { + if (string.IsNullOrEmpty(verb)) + { + continue; + } + + yield return new ElementGranularPermission + { + Key = elementPermissionPresentationModel.Element.Id, + Permission = verb, + }; + } + } + + /// + public IEnumerable AggregatePresentationModels(IUser user, IEnumerable models) + { + // Get the unique element keys that have granular permissions. + Guid[] elementKeysWithGranularPermissions = models + .Cast() + .Select(x => x.Element.Id) + .Distinct() + .ToArray(); + + // Batch retrieve all elements by their keys. + var elements = _entityService.Value.GetAll(elementKeysWithGranularPermissions) + .ToDictionary(elem => elem.Key, elem => elem.Path); + + // Iterate through each element key that has granular permissions. + foreach (Guid elementKey in elementKeysWithGranularPermissions) + { + // Retrieve the path from the pre-fetched elements. + if (!elements.TryGetValue(elementKey, out var path) || string.IsNullOrEmpty(path)) + { + continue; + } + + // With the path we can call the same logic as used server-side for authorizing access to resources. + EntityPermissionSet permissionsForPath = _userService.Value.GetPermissionsForPath(user, path); + yield return new ElementPermissionPresentationModel + { + Element = new ReferenceByIdModel(elementKey), + Verbs = permissionsForPath.GetAllPermissions(), + }; + } + } +} diff --git a/src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs b/src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs index d125fcd73571..adb97ab5775c 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs @@ -17,6 +17,7 @@ public static class SectionMapper new SectionMapping { Alias = "translation", Name = "Umb.Section.Translation" }, new SectionMapping { Alias = "users", Name = "Umb.Section.Users" }, new SectionMapping { Alias = "forms", Name = "Umb.Section.Forms" }, + new SectionMapping { Alias = "library", Name = "Umb.Section.Library" }, }; public static string GetName(string alias) diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 57392e2f4f47..3a987ebab8ec 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -11732,13 +11732,29 @@ ] } }, - "/umbraco/management/api/v1/health-check-group": { + "/umbraco/management/api/v1/element-version": { "get": { "tags": [ - "Health Check" + "Element Version" ], - "operationId": "GetHealthCheckGroup", + "operationId": "GetElementVersion", "parameters": [ + { + "name": "elementId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "culture", + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "skip", "in": "query", @@ -11766,7 +11782,35 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedHealthCheckGroupResponseModel" + "$ref": "#/components/schemas/PagedElementVersionItemResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -11775,9 +11819,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -11787,23 +11828,38 @@ ] } }, - "/umbraco/management/api/v1/health-check-group/{name}": { + "/umbraco/management/api/v1/element-version/{id}": { "get": { "tags": [ - "Health Check" + "Element Version" ], - "operationId": "GetHealthCheckGroupByName", + "operationId": "GetElementVersionById", "parameters": [ { - "name": "name", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementVersionResponseModel" + } + ] + } + } + } + }, "404": { "description": "Not Found", "content": { @@ -11818,14 +11874,14 @@ } } }, - "200": { - "description": "OK", + "400": { + "description": "Bad Request", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/HealthCheckGroupPresentationModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -11834,9 +11890,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -11846,23 +11899,46 @@ ] } }, - "/umbraco/management/api/v1/health-check-group/{name}/check": { - "post": { + "/umbraco/management/api/v1/element-version/{id}/prevent-cleanup": { + "put": { "tags": [ - "Health Check" + "Element Version" ], - "operationId": "PostHealthCheckGroupByNameCheck", + "operationId": "PutElementVersionByIdPreventCleanup", "parameters": [ { - "name": "name", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" + } + }, + { + "name": "preventCleanup", + "in": "query", + "schema": { + "type": "boolean" } } ], "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "404": { "description": "Not Found", "headers": { @@ -11889,8 +11965,8 @@ } } }, - "200": { - "description": "OK", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -11908,7 +11984,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/HealthCheckGroupWithResultResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -11917,9 +11993,83 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/element-version/{id}/rollback": { + "post": { + "tags": [ + "Element Version" + ], + "operationId": "PostElementVersionByIdRollback", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } }, - "403": { - "description": "The authenticated user does not have access to this resource", + { + "name": "culture", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -11931,7 +12081,21 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } + }, + "401": { + "description": "The resource is protected and requires an authentication token" } }, "security": [ @@ -11941,19 +12105,19 @@ ] } }, - "/umbraco/management/api/v1/health-check/execute-action": { + "/umbraco/management/api/v1/element": { "post": { "tags": [ - "Health Check" + "Element" ], - "operationId": "PostHealthCheckExecuteAction", + "operationId": "PostElement", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/HealthCheckActionRequestModel" + "$ref": "#/components/schemas/CreateElementRequestModel" } ] } @@ -11962,7 +12126,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/HealthCheckActionRequestModel" + "$ref": "#/components/schemas/CreateElementRequestModel" } ] } @@ -11971,7 +12135,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/HealthCheckActionRequestModel" + "$ref": "#/components/schemas/CreateElementRequestModel" } ] } @@ -11979,6 +12143,36 @@ } }, "responses": { + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "400": { "description": "Bad Request", "headers": { @@ -12005,8 +12199,8 @@ } } }, - "200": { - "description": "OK", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -12024,7 +12218,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/HealthCheckResultResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -12057,77 +12251,46 @@ ] } }, - "/umbraco/management/api/v1/help": { + "/umbraco/management/api/v1/element/{id}": { "get": { "tags": [ - "Help" + "Element" ], - "operationId": "GetHelp", + "operationId": "GetElementById", "parameters": [ { - "name": "section", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "tree", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - }, - { - "name": "baseUrl", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { "type": "string", - "default": "https://our.umbraco.com" + "format": "uuid" } } ], "responses": { - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/ElementResponseModel" } ] } } } }, - "200": { - "description": "OK", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedHelpPageResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -12136,118 +12299,69 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, - "deprecated": true, "security": [ { "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/imaging/resize/urls": { - "get": { + }, + "delete": { "tags": [ - "Imaging" + "Element" ], - "operationId": "GetImagingResizeUrls", + "operationId": "DeleteElementById", "parameters": [ { "name": "id", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - }, - { - "name": "height", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 200 - } - }, - { - "name": "width", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 200 - } - }, - { - "name": "mode", - "in": "query", + "in": "path", + "required": true, "schema": { - "$ref": "#/components/schemas/ImageCropModeModel" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { "description": "OK", - "content": { - "application/json": { + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaUrlInfoResponseModel" - } - ] - } + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } } }, - "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/import/analyze": { - "get": { - "tags": [ - "Import" - ], - "operationId": "GetImportAnalyze", - "parameters": [ - { - "name": "temporaryFileId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK", + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/EntityImportAnalysisResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -12256,20 +12370,18 @@ }, "404": { "description": "Not Found", - "content": { - "application/json": { + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/json": { "schema": { @@ -12284,58 +12396,21 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/indexer": { - "get": { - "tags": [ - "Indexer" - ], - "operationId": "GetIndexer", - "parameters": [ - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedIndexResponseModel" - } - ] + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } } - }, - "401": { - "description": "The resource is protected and requires an authentication token" } }, "security": [ @@ -12343,83 +12418,57 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/indexer/{indexName}": { - "get": { + }, + "put": { "tags": [ - "Indexer" + "Element" ], - "operationId": "GetIndexerByIndexName", + "operationId": "PutElementById", "parameters": [ { - "name": "indexName", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateElementRequestModel" + } + ] } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/IndexResponseModel" - } - ] - } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateElementRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateElementRequestModel" + } + ] } } - }, - "401": { - "description": "The resource is protected and requires an authentication token" } }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/indexer/{indexName}/rebuild": { - "post": { - "tags": [ - "Indexer" - ], - "operationId": "PostIndexerByIndexNameRebuild", - "parameters": [ - { - "name": "indexName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], "responses": { - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -12431,21 +12480,10 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -12470,8 +12508,8 @@ } } }, - "409": { - "description": "Conflict", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -12496,8 +12534,11 @@ } } }, - "200": { - "description": "OK", + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -12510,9 +12551,6 @@ } } } - }, - "401": { - "description": "The resource is protected and requires an authentication token" } }, "security": [ @@ -12522,57 +12560,30 @@ ] } }, - "/umbraco/management/api/v1/install/settings": { - "get": { + "/umbraco/management/api/v1/element/{id}/copy": { + "post": { "tags": [ - "Install" + "Element" ], - "operationId": "GetInstallSettings", - "responses": { - "428": { - "description": "Precondition Required", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/InstallSettingsResponseModel" - } - ] - } - } + "operationId": "PostElementByIdCopy", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } } - } - } - }, - "/umbraco/management/api/v1/install/setup": { - "post": { - "tags": [ - "Install" ], - "operationId": "PostInstallSetup", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/InstallRequestModel" + "$ref": "#/components/schemas/CopyElementRequestModel" } ] } @@ -12581,7 +12592,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/InstallRequestModel" + "$ref": "#/components/schemas/CopyElementRequestModel" } ] } @@ -12590,7 +12601,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/InstallRequestModel" + "$ref": "#/components/schemas/CopyElementRequestModel" } ] } @@ -12598,8 +12609,38 @@ } }, "responses": { - "428": { - "description": "Precondition Required", + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -12624,8 +12665,11 @@ } } }, - "200": { - "description": "OK", + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -12639,22 +12683,38 @@ } } } - } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] } }, - "/umbraco/management/api/v1/install/validate-database": { - "post": { + "/umbraco/management/api/v1/element/{id}/move": { + "put": { "tags": [ - "Install" + "Element" + ], + "operationId": "PutElementByIdMove", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "PostInstallValidateDatabase", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/DatabaseInstallRequestModel" + "$ref": "#/components/schemas/MoveElementRequestModel" } ] } @@ -12663,7 +12723,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/DatabaseInstallRequestModel" + "$ref": "#/components/schemas/MoveElementRequestModel" } ] } @@ -12672,7 +12732,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/DatabaseInstallRequestModel" + "$ref": "#/components/schemas/MoveElementRequestModel" } ] } @@ -12680,8 +12740,23 @@ } }, "responses": { - "400": { - "description": "Bad Request", + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -12706,8 +12781,11 @@ } } }, - "200": { - "description": "OK", + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -12721,124 +12799,93 @@ } } } - } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] } }, - "/umbraco/management/api/v1/item/language": { - "get": { + "/umbraco/management/api/v1/element/{id}/move-to-recycle-bin": { + "put": { "tags": [ - "Language" + "Element" ], - "operationId": "GetItemLanguage", + "operationId": "PutElementByIdMoveToRecycleBin", "parameters": [ { - "name": "isoCode", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string" - } + "type": "string", + "format": "uuid" } } ], "responses": { "200": { "description": "OK", - "content": { - "application/json": { + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/LanguageItemResponseModel" - } - ] - } + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/item/language/default": { - "get": { - "tags": [ - "Language" - ], - "operationId": "GetItemLanguageDefault", - "responses": { - "200": { - "description": "OK", + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/LanguageItemResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/language": { - "get": { - "tags": [ - "Language" - ], - "operationId": "GetLanguage", - "parameters": [ - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedLanguageResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -12847,6 +12894,21 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -12854,19 +12916,32 @@ "Backoffice-User": [ ] } ] - }, - "post": { + } + }, + "/umbraco/management/api/v1/element/{id}/publish": { + "put": { "tags": [ - "Language" + "Element" + ], + "operationId": "PutElementByIdPublish", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "PostLanguage", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateLanguageRequestModel" + "$ref": "#/components/schemas/PublishElementRequestModel" } ] } @@ -12875,7 +12950,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateLanguageRequestModel" + "$ref": "#/components/schemas/PublishElementRequestModel" } ] } @@ -12884,7 +12959,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateLanguageRequestModel" + "$ref": "#/components/schemas/PublishElementRequestModel" } ] } @@ -12892,8 +12967,8 @@ } }, "responses": { - "404": { - "description": "Not Found", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -12905,17 +12980,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "400": { @@ -12944,24 +13008,9 @@ } } }, - "201": { - "description": "Created", + "404": { + "description": "Not Found", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -12972,6 +13021,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "401": { @@ -13000,77 +13060,70 @@ ] } }, - "/umbraco/management/api/v1/language/{isoCode}": { - "get": { + "/umbraco/management/api/v1/element/{id}/unpublish": { + "put": { "tags": [ - "Language" + "Element" ], - "operationId": "GetLanguageByIsoCode", + "operationId": "PutElementByIdUnpublish", "parameters": [ { - "name": "isoCode", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnpublishElementRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnpublishElementRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnpublishElementRequestModel" + } + ] + } + } + } + }, "responses": { - "404": { - "description": "Not Found", - "content": { - "application/json": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } } }, - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/LanguageResponseModel" - } - ] - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - }, - "delete": { - "tags": [ - "Language" - ], - "operationId": "DeleteLanguageByIsoCode", - "parameters": [ - { - "name": "isoCode", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { "400": { "description": "Bad Request", "headers": { @@ -13123,21 +13176,6 @@ } } }, - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -13162,19 +13200,22 @@ "Backoffice-User": [ ] } ] - }, + } + }, + "/umbraco/management/api/v1/element/{id}/validate": { "put": { "tags": [ - "Language" + "Element" ], - "operationId": "PutLanguageByIsoCode", + "operationId": "PutElementByIdValidate", "parameters": [ { - "name": "isoCode", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -13184,7 +13225,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateLanguageRequestModel" + "$ref": "#/components/schemas/ValidateUpdateElementRequestModel" } ] } @@ -13193,7 +13234,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateLanguageRequestModel" + "$ref": "#/components/schemas/ValidateUpdateElementRequestModel" } ] } @@ -13202,7 +13243,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateLanguageRequestModel" + "$ref": "#/components/schemas/ValidateUpdateElementRequestModel" } ] } @@ -13210,8 +13251,8 @@ } }, "responses": { - "404": { - "description": "Not Found", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -13223,17 +13264,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "400": { @@ -13262,8 +13292,8 @@ } } }, - "200": { - "description": "OK", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -13275,6 +13305,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "401": { @@ -13303,32 +13344,12 @@ ] } }, - "/umbraco/management/api/v1/log-viewer/level": { + "/umbraco/management/api/v1/element/configuration": { "get": { "tags": [ - "Log Viewer" - ], - "operationId": "GetLogViewerLevel", - "parameters": [ - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } + "Element" ], + "operationId": "GetElementConfiguration", "responses": { "200": { "description": "OK", @@ -13337,7 +13358,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedLoggerResponseModel" + "$ref": "#/components/schemas/ElementConfigurationResponseModel" } ] } @@ -13358,33 +13379,88 @@ ] } }, - "/umbraco/management/api/v1/log-viewer/level-count": { - "get": { + "/umbraco/management/api/v1/element/folder": { + "post": { "tags": [ - "Log Viewer" + "Element" ], - "operationId": "GetLogViewerLevelCount", - "parameters": [ - { - "name": "startDate", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "endDate", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" + "operationId": "PostElementFolder", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateFolderRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateFolderRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateFolderRequestModel" + } + ] + } } } - ], + }, "responses": { + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "400": { "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { @@ -13397,14 +13473,26 @@ } } }, - "200": { - "description": "OK", + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/LogLevelCountsReponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -13415,7 +13503,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -13425,69 +13525,20 @@ ] } }, - "/umbraco/management/api/v1/log-viewer/log": { + "/umbraco/management/api/v1/element/folder/{id}": { "get": { "tags": [ - "Log Viewer" + "Element" ], - "operationId": "GetLogViewerLog", + "operationId": "GetElementFolderById", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - }, - { - "name": "orderDirection", - "in": "query", - "schema": { - "$ref": "#/components/schemas/DirectionModel" - } - }, - { - "name": "filterExpression", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "logLevel", - "in": "query", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LogLevelModel" - } - } - }, - { - "name": "startDate", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "endDate", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { "type": "string", - "format": "date-time" + "format": "uuid" } } ], @@ -13499,7 +13550,21 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedLogMessageResponseModel" + "$ref": "#/components/schemas/FolderResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -13518,53 +13583,53 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/log-viewer/message-template": { - "get": { + }, + "delete": { "tags": [ - "Log Viewer" + "Element" ], - "operationId": "GetLogViewerMessageTemplate", + "operationId": "DeleteElementFolderById", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - }, - { - "name": "startDate", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "endDate", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { "type": "string", - "format": "date-time" + "format": "uuid" } } ], "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "400": { "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { @@ -13577,14 +13642,26 @@ } } }, - "200": { - "description": "OK", + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedLogTemplateResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -13595,7 +13672,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -13603,74 +13692,30 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/log-viewer/saved-search": { - "get": { + }, + "put": { "tags": [ - "Log Viewer" + "Element" ], - "operationId": "GetLogViewerSavedSearch", + "operationId": "PutElementFolderById", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedSavedLogSearchResponseModel" - } - ] - } - } + "type": "string", + "format": "uuid" } - }, - "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": [ ] } - ] - }, - "post": { - "tags": [ - "Log Viewer" ], - "operationId": "PostLogViewerSavedSearch", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SavedLogSearchRequestModel" + "$ref": "#/components/schemas/UpdateFolderResponseModel" } ] } @@ -13679,7 +13724,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SavedLogSearchRequestModel" + "$ref": "#/components/schemas/UpdateFolderResponseModel" } ] } @@ -13688,7 +13733,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SavedLogSearchRequestModel" + "$ref": "#/components/schemas/UpdateFolderResponseModel" } ] } @@ -13696,6 +13741,21 @@ } }, "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "400": { "description": "Bad Request", "headers": { @@ -13722,26 +13782,11 @@ } } }, - "201": { - "description": "Created", + "404": { + "description": "Not Found", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { @@ -13750,6 +13795,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "401": { @@ -13778,25 +13834,84 @@ ] } }, - "/umbraco/management/api/v1/log-viewer/saved-search/{name}": { - "get": { + "/umbraco/management/api/v1/element/folder/{id}/move": { + "put": { "tags": [ - "Log Viewer" + "Element" ], - "operationId": "GetLogViewerSavedSearchByName", + "operationId": "PutElementFolderByIdMove", "parameters": [ { - "name": "name", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MoveFolderRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MoveFolderRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MoveFolderRequestModel" + } + ] + } + } + } + }, "responses": { - "404": { - "description": "Not Found", + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { @@ -13809,14 +13924,26 @@ } } }, - "200": { - "description": "OK", + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SavedLogSearchResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -13827,7 +13954,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -13835,25 +13974,43 @@ "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/element/folder/{id}/move-to-recycle-bin": { + "put": { "tags": [ - "Log Viewer" + "Element" ], - "operationId": "DeleteLogViewerSavedSearchByName", + "operationId": "PutElementFolderByIdMoveToRecycleBin", "parameters": [ { - "name": "name", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { - "404": { - "description": "Not Found", + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -13878,8 +14035,8 @@ } } }, - "200": { - "description": "OK", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -13891,6 +14048,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "401": { @@ -13919,33 +14087,73 @@ ] } }, - "/umbraco/management/api/v1/log-viewer/validate-logs-size": { - "get": { + "/umbraco/management/api/v1/element/validate": { + "post": { "tags": [ - "Log Viewer" + "Element" ], - "operationId": "GetLogViewerValidateLogsSize", - "parameters": [ - { - "name": "startDate", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "endDate", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" + "operationId": "PostElementValidate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateElementRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateElementRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateElementRequestModel" + } + ] + } } } - ], + }, "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "400": { "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { @@ -13958,14 +14166,49 @@ } } }, - "200": { - "description": "OK" + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -13975,12 +14218,26 @@ ] } }, - "/umbraco/management/api/v1/manifest/manifest": { + "/umbraco/management/api/v1/item/element": { "get": { "tags": [ - "Manifest" + "Element" + ], + "operationId": "GetItemElement", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } ], - "operationId": "GetManifestManifest", "responses": { "200": { "description": "OK", @@ -13991,7 +14248,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/ManifestResponseModel" + "$ref": "#/components/schemas/ElementItemResponseModel" } ] } @@ -14001,9 +14258,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -14013,12 +14267,26 @@ ] } }, - "/umbraco/management/api/v1/manifest/manifest/private": { + "/umbraco/management/api/v1/item/element/folder": { "get": { "tags": [ - "Manifest" + "Element" + ], + "operationId": "GetItemElementFolder", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } ], - "operationId": "GetManifestManifestPrivate", "responses": { "200": { "description": "OK", @@ -14029,7 +14297,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/ManifestResponseModel" + "$ref": "#/components/schemas/FolderItemResponseModel" } ] } @@ -14039,9 +14307,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -14051,73 +14316,182 @@ ] } }, - "/umbraco/management/api/v1/manifest/manifest/public": { - "get": { + "/umbraco/management/api/v1/recycle-bin/element": { + "delete": { "tags": [ - "Manifest" + "Element" ], - "operationId": "GetManifestManifestPublic", + "operationId": "DeleteRecycleBinElement", "responses": { "200": { "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ManifestResponseModel" - } - ] - } + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } } } - } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] } }, - "/umbraco/management/api/v1/item/media-type": { - "get": { + "/umbraco/management/api/v1/recycle-bin/element/{id}": { + "delete": { "tags": [ - "Media Type" + "Element" ], - "operationId": "GetItemMediaType", + "operationId": "DeleteRecycleBinElementById", "parameters": [ { "name": "id", - "in": "query", + "in": "path", + "required": true, "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } + "type": "string", + "format": "uuid" } } ], "responses": { "200": { "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTypeItemResponseModel" - } - ] - } + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -14127,18 +14501,19 @@ ] } }, - "/umbraco/management/api/v1/item/media-type/allowed": { + "/umbraco/management/api/v1/recycle-bin/element/children": { "get": { "tags": [ - "Media Type" + "Element" ], - "operationId": "GetItemMediaTypeAllowed", + "operationId": "GetRecycleBinElementChildren", "parameters": [ { - "name": "fileExtension", + "name": "parentId", "in": "query", "schema": { - "type": "string" + "type": "string", + "format": "uuid" } }, { @@ -14168,7 +14543,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedModelMediaTypeItemResponseModel" + "$ref": "#/components/schemas/PagedElementRecycleBinItemResponseModel" } ] } @@ -14177,6 +14552,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -14186,173 +14564,27 @@ ] } }, - "/umbraco/management/api/v1/item/media-type/folders": { - "get": { + "/umbraco/management/api/v1/recycle-bin/element/folder/{id}": { + "delete": { "tags": [ - "Media Type" + "Element" ], - "operationId": "GetItemMediaTypeFolders", + "operationId": "DeleteRecycleBinElementFolderById", "parameters": [ { - "name": "skip", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedModelMediaTypeItemResponseModel" - } - ] - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/item/media-type/search": { - "get": { - "tags": [ - "Media Type" - ], - "operationId": "GetItemMediaTypeSearch", - "parameters": [ - { - "name": "query", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "type": "string", + "format": "uuid" } } ], "responses": { "200": { "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedModelMediaTypeItemResponseModel" - } - ] - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/media-type": { - "post": { - "tags": [ - "Media Type" - ], - "operationId": "PostMediaType", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateMediaTypeRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateMediaTypeRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateMediaTypeRequestModel" - } - ] - } - } - } - }, - "responses": { - "201": { - "description": "Created", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -14443,20 +14675,29 @@ ] } }, - "/umbraco/management/api/v1/media-type/{id}": { + "/umbraco/management/api/v1/recycle-bin/element/root": { "get": { "tags": [ - "Media Type" + "Element" ], - "operationId": "GetMediaTypeById", + "operationId": "GetRecycleBinElementRoot", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "skip", + "in": "query", "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } ], @@ -14468,21 +14709,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MediaTypeResponseModel" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedElementRecycleBinItemResponseModel" } ] } @@ -14501,17 +14728,42 @@ "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/recycle-bin/element/siblings": { + "get": { "tags": [ - "Media Type" + "Element" ], - "operationId": "DeleteMediaTypeById", + "operationId": "GetRecycleBinElementSiblings", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "target", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "dataTypeId", + "in": "query", "schema": { "type": "string", "format": "uuid" @@ -14521,39 +14773,12 @@ "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/SubsetElementRecycleBinItemResponseModel" } ] } @@ -14564,19 +14789,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -14584,118 +14797,38 @@ "Backoffice-User": [ ] } ] - }, - "put": { + } + }, + "/umbraco/management/api/v1/tree/element/ancestors": { + "get": { "tags": [ - "Media Type" + "Element" ], - "operationId": "PutMediaTypeById", + "operationId": "GetTreeElementAncestors", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "descendantId", + "in": "query", "schema": { "type": "string", "format": "uuid" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpdateMediaTypeRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpdateMediaTypeRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpdateMediaTypeRequestModel" - } - ] - } - } - } - }, "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] + "oneOf": [ + { + "$ref": "#/components/schemas/ElementTreeItemResponseModel" + } + ] + } } } } @@ -14704,19 +14837,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -14726,24 +14847,15 @@ ] } }, - "/umbraco/management/api/v1/media-type/{id}/allowed-children": { + "/umbraco/management/api/v1/tree/element/children": { "get": { "tags": [ - "Media Type" + "Element" ], - "operationId": "GetMediaTypeByIdAllowedChildren", + "operationId": "GetTreeElementChildren", "parameters": [ { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "parentContentKey", + "name": "parentId", "in": "query", "schema": { "type": "string", @@ -14767,6 +14879,14 @@ "format": "int32", "default": 100 } + }, + { + "name": "foldersOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -14777,21 +14897,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedAllowedMediaTypeModel" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedElementTreeItemResponseModel" } ] } @@ -14812,63 +14918,49 @@ ] } }, - "/umbraco/management/api/v1/media-type/{id}/composition-references": { + "/umbraco/management/api/v1/tree/element/root": { "get": { "tags": [ - "Media Type" + "Element" ], - "operationId": "GetMediaTypeByIdCompositionReferences", + "operationId": "GetTreeElementRoot", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "skip", + "in": "query", "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "foldersOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": false } } ], "responses": { "200": { "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTypeCompositionResponseModel" - } - ] - } - } - } - } - }, - "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" + "$ref": "#/components/schemas/PagedElementTreeItemResponseModel" } ] } @@ -14889,131 +14981,110 @@ ] } }, - "/umbraco/management/api/v1/media-type/{id}/copy": { - "post": { + "/umbraco/management/api/v1/tree/element/siblings": { + "get": { "tags": [ - "Media Type" + "Element" ], - "operationId": "PostMediaTypeByIdCopy", + "operationId": "GetTreeElementSiblings", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "target", + "in": "query", "schema": { "type": "string", "format": "uuid" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CopyMediaTypeRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CopyMediaTypeRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CopyMediaTypeRequestModel" - } - ] - } + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" } - } - }, - "responses": { - "201": { - "description": "Created", - "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + { + "name": "foldersOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/SubsetElementTreeItemResponseModel" } ] } } } }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "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/health-check-group": { + "get": { + "tags": [ + "Health Check" + ], + "operationId": "GetHealthCheckGroup", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedHealthCheckGroupResponseModel" } ] } @@ -15024,19 +15095,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -15046,47 +15105,45 @@ ] } }, - "/umbraco/management/api/v1/media-type/{id}/export": { + "/umbraco/management/api/v1/health-check-group/{name}": { "get": { "tags": [ - "Media Type" + "Health Check" ], - "operationId": "GetMediaTypeByIdExport", + "operationId": "GetHealthCheckGroupByName", "parameters": [ { - "name": "id", + "name": "name", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/ProblemDetails" } ] } } } }, - "404": { - "description": "Not Found", + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/HealthCheckGroupPresentationModel" } ] } @@ -15107,70 +15164,23 @@ ] } }, - "/umbraco/management/api/v1/media-type/{id}/import": { - "put": { + "/umbraco/management/api/v1/health-check-group/{name}/check": { + "post": { "tags": [ - "Media Type" + "Health Check" ], - "operationId": "PutMediaTypeByIdImport", + "operationId": "PostHealthCheckGroupByNameCheck", "parameters": [ { - "name": "id", + "name": "name", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ImportMediaTypeRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ImportMediaTypeRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ImportMediaTypeRequestModel" - } - ] - } - } - } - }, "responses": { - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "404": { "description": "Not Found", "headers": { @@ -15197,8 +15207,8 @@ } } }, - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -15216,7 +15226,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/HealthCheckGroupWithResultResponseModel" } ] } @@ -15249,30 +15259,19 @@ ] } }, - "/umbraco/management/api/v1/media-type/{id}/move": { - "put": { + "/umbraco/management/api/v1/health-check/execute-action": { + "post": { "tags": [ - "Media Type" - ], - "operationId": "PutMediaTypeByIdMove", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Health Check" ], + "operationId": "PostHealthCheckExecuteAction", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MoveMediaTypeRequestModel" + "$ref": "#/components/schemas/HealthCheckActionRequestModel" } ] } @@ -15281,7 +15280,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MoveMediaTypeRequestModel" + "$ref": "#/components/schemas/HealthCheckActionRequestModel" } ] } @@ -15290,7 +15289,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MoveMediaTypeRequestModel" + "$ref": "#/components/schemas/HealthCheckActionRequestModel" } ] } @@ -15298,21 +15297,6 @@ } }, "responses": { - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "400": { "description": "Bad Request", "headers": { @@ -15339,8 +15323,8 @@ } } }, - "404": { - "description": "Not Found", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -15358,7 +15342,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/HealthCheckResultResponseModel" } ] } @@ -15391,13 +15375,27 @@ ] } }, - "/umbraco/management/api/v1/media-type/allowed-at-root": { + "/umbraco/management/api/v1/help": { "get": { "tags": [ - "Media Type" + "Help" ], - "operationId": "GetMediaTypeAllowedAtRoot", + "operationId": "GetHelp", "parameters": [ + { + "name": "section", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "tree", + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "skip", "in": "query", @@ -15415,9 +15413,31 @@ "format": "int32", "default": 100 } + }, + { + "name": "baseUrl", + "in": "query", + "schema": { + "type": "string", + "default": "https://our.umbraco.com" + } } ], "responses": { + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "200": { "description": "OK", "content": { @@ -15425,7 +15445,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedAllowedMediaTypeModel" + "$ref": "#/components/schemas/PagedHelpPageResponseModel" } ] } @@ -15434,11 +15454,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, + "deprecated": true, "security": [ { "Backoffice-User": [ ] @@ -15446,58 +15464,54 @@ ] } }, - "/umbraco/management/api/v1/media-type/available-compositions": { - "post": { + "/umbraco/management/api/v1/imaging/resize/urls": { + "get": { "tags": [ - "Media Type" + "Imaging" ], - "operationId": "PostMediaTypeAvailableCompositions", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTypeCompositionRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTypeCompositionRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTypeCompositionRequestModel" - } - ] + "operationId": "GetImagingResizeUrls", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" } } + }, + { + "name": "height", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 200 + } + }, + { + "name": "width", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 200 + } + }, + { + "name": "mode", + "in": "query", + "schema": { + "$ref": "#/components/schemas/ImageCropModeModel" + } } - }, + ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { @@ -15505,7 +15519,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/AvailableMediaTypeCompositionResponseModel" + "$ref": "#/components/schemas/MediaUrlInfoResponseModel" } ] } @@ -15517,19 +15531,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -15539,12 +15541,22 @@ ] } }, - "/umbraco/management/api/v1/media-type/configuration": { + "/umbraco/management/api/v1/import/analyze": { "get": { "tags": [ - "Media Type" + "Import" + ], + "operationId": "GetImportAnalyze", + "parameters": [ + { + "name": "temporaryFileId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "GetMediaTypeConfiguration", "responses": { "200": { "description": "OK", @@ -15553,7 +15565,35 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MediaTypeConfigurationResponseModel" + "$ref": "#/components/schemas/EntityImportAnalysisResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -15562,9 +15602,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -15574,120 +15611,41 @@ ] } }, - "/umbraco/management/api/v1/media-type/folder": { - "post": { + "/umbraco/management/api/v1/indexer": { + "get": { "tags": [ - "Media Type" + "Indexer" ], - "operationId": "PostMediaTypeFolder", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateFolderRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateFolderRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateFolderRequestModel" - } - ] - } - } - } - }, - "responses": { - "201": { - "description": "Created", - "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } + "operationId": "GetIndexer", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedIndexResponseModel" } ] } @@ -15696,21 +15654,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } } }, "security": [ @@ -15720,46 +15663,45 @@ ] } }, - "/umbraco/management/api/v1/media-type/folder/{id}": { + "/umbraco/management/api/v1/indexer/{indexName}": { "get": { "tags": [ - "Media Type" + "Indexer" ], - "operationId": "GetMediaTypeFolderById", + "operationId": "GetIndexerByIndexName", "parameters": [ { - "name": "id", + "name": "indexName", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { - "200": { - "description": "OK", + "400": { + "description": "Bad Request", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/FolderResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } } } }, - "404": { - "description": "Not Found", + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/IndexResponseModel" } ] } @@ -15768,9 +15710,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -15778,26 +15717,27 @@ "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/indexer/{indexName}/rebuild": { + "post": { "tags": [ - "Media Type" + "Indexer" ], - "operationId": "DeleteMediaTypeFolderById", + "operationId": "PostIndexerByIndexNameRebuild", "parameters": [ { - "name": "id", + "name": "indexName", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { - "200": { - "description": "OK", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -15809,10 +15749,21 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -15837,8 +15788,8 @@ } } }, - "404": { - "description": "Not Found", + "409": { + "description": "Conflict", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -15863,11 +15814,8 @@ } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -15880,6 +15828,9 @@ } } } + }, + "401": { + "description": "The resource is protected and requires an authentication token" } }, "security": [ @@ -15887,30 +15838,59 @@ "Backoffice-User": [ ] } ] - }, - "put": { + } + }, + "/umbraco/management/api/v1/install/settings": { + "get": { "tags": [ - "Media Type" + "Install" ], - "operationId": "PutMediaTypeFolderById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "operationId": "GetInstallSettings", + "responses": { + "428": { + "description": "Precondition Required", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/InstallSettingsResponseModel" + } + ] + } + } } } + } + } + }, + "/umbraco/management/api/v1/install/setup": { + "post": { + "tags": [ + "Install" ], + "operationId": "PostInstallSetup", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateFolderResponseModel" + "$ref": "#/components/schemas/InstallRequestModel" } ] } @@ -15919,7 +15899,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateFolderResponseModel" + "$ref": "#/components/schemas/InstallRequestModel" } ] } @@ -15928,7 +15908,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateFolderResponseModel" + "$ref": "#/components/schemas/InstallRequestModel" } ] } @@ -15936,49 +15916,8 @@ } }, "responses": { - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", + "428": { + "description": "Precondition Required", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -16003,11 +15942,8 @@ } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -16021,27 +15957,22 @@ } } } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] + } } }, - "/umbraco/management/api/v1/media-type/import": { + "/umbraco/management/api/v1/install/validate-database": { "post": { "tags": [ - "Media Type" + "Install" ], - "operationId": "PostMediaTypeImport", + "operationId": "PostInstallValidateDatabase", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ImportMediaTypeRequestModel" + "$ref": "#/components/schemas/DatabaseInstallRequestModel" } ] } @@ -16050,7 +15981,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ImportMediaTypeRequestModel" + "$ref": "#/components/schemas/DatabaseInstallRequestModel" } ] } @@ -16059,7 +15990,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ImportMediaTypeRequestModel" + "$ref": "#/components/schemas/DatabaseInstallRequestModel" } ] } @@ -16067,62 +15998,6 @@ } }, "responses": { - "201": { - "description": "Created", - "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, "400": { "description": "Bad Request", "headers": { @@ -16149,11 +16024,8 @@ } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -16167,27 +16039,25 @@ } } } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] + } } }, - "/umbraco/management/api/v1/tree/media-type/ancestors": { + "/umbraco/management/api/v1/item/language": { "get": { "tags": [ - "Media Type" + "Language" ], - "operationId": "GetTreeMediaTypeAncestors", + "operationId": "GetItemLanguage", "parameters": [ { - "name": "descendantId", + "name": "isoCode", "in": "query", "schema": { - "type": "string", - "format": "uuid" + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } } } ], @@ -16201,7 +16071,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/MediaTypeTreeItemResponseModel" + "$ref": "#/components/schemas/LanguageItemResponseModel" } ] } @@ -16211,9 +16081,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -16223,48 +16090,12 @@ ] } }, - "/umbraco/management/api/v1/tree/media-type/children": { + "/umbraco/management/api/v1/item/language/default": { "get": { "tags": [ - "Media Type" - ], - "operationId": "GetTreeMediaTypeChildren", - "parameters": [ - { - "name": "parentId", - "in": "query", - "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": 100 - } - }, - { - "name": "foldersOnly", - "in": "query", - "schema": { - "type": "boolean", - "default": false - } - } + "Language" ], + "operationId": "GetItemLanguageDefault", "responses": { "200": { "description": "OK", @@ -16273,7 +16104,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedMediaTypeTreeItemResponseModel" + "$ref": "#/components/schemas/LanguageItemResponseModel" } ] } @@ -16282,9 +16113,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -16294,12 +16122,12 @@ ] } }, - "/umbraco/management/api/v1/tree/media-type/root": { + "/umbraco/management/api/v1/language": { "get": { "tags": [ - "Media Type" + "Language" ], - "operationId": "GetTreeMediaTypeRoot", + "operationId": "GetLanguage", "parameters": [ { "name": "skip", @@ -16318,14 +16146,6 @@ "format": "int32", "default": 100 } - }, - { - "name": "foldersOnly", - "in": "query", - "schema": { - "type": "boolean", - "default": false - } } ], "responses": { @@ -16336,7 +16156,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedMediaTypeTreeItemResponseModel" + "$ref": "#/components/schemas/PagedLanguageResponseModel" } ] } @@ -16345,9 +16165,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -16355,360 +16172,19 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/tree/media-type/siblings": { - "get": { + }, + "post": { "tags": [ - "Media Type" + "Language" ], - "operationId": "GetTreeMediaTypeSiblings", - "parameters": [ - { - "name": "target", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "before", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "after", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "foldersOnly", - "in": "query", - "schema": { - "type": "boolean", - "default": false - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/SubsetMediaTypeTreeItemResponseModel" - } - ] - } - } - } - }, - "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/collection/media": { - "get": { - "tags": [ - "Media" - ], - "operationId": "GetCollectionMedia", - "parameters": [ - { - "name": "id", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "dataTypeId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "orderBy", - "in": "query", - "schema": { - "type": "string", - "default": "updateDate" - } - }, - { - "name": "orderDirection", - "in": "query", - "schema": { - "$ref": "#/components/schemas/DirectionModel" - } - }, - { - "name": "filter", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedMediaCollectionResponseModel" - } - ] - } - } - } - }, - "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/item/media": { - "get": { - "tags": [ - "Media" - ], - "operationId": "GetItemMedia", - "parameters": [ - { - "name": "id", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaItemResponseModel" - } - ] - } - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/item/media/search": { - "get": { - "tags": [ - "Media" - ], - "operationId": "GetItemMediaSearch", - "parameters": [ - { - "name": "query", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "trashed", - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "culture", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - }, - { - "name": "parentId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "allowedMediaTypes", - "in": "query", - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - }, - { - "name": "dataTypeId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedModelMediaItemResponseModel" - } - ] - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/media": { - "post": { - "tags": [ - "Media" - ], - "operationId": "PostMedia", + "operationId": "PostLanguage", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMediaRequestModel" + "$ref": "#/components/schemas/CreateLanguageRequestModel" } ] } @@ -16717,7 +16193,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMediaRequestModel" + "$ref": "#/components/schemas/CreateLanguageRequestModel" } ] } @@ -16726,7 +16202,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMediaRequestModel" + "$ref": "#/components/schemas/CreateLanguageRequestModel" } ] } @@ -16734,24 +16210,9 @@ } }, "responses": { - "201": { - "description": "Created", + "404": { + "description": "Not Found", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -16762,6 +16223,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "400": { @@ -16790,9 +16262,24 @@ } } }, - "404": { - "description": "Not Found", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -16803,17 +16290,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "401": { @@ -16842,46 +16318,45 @@ ] } }, - "/umbraco/management/api/v1/media/{id}": { + "/umbraco/management/api/v1/language/{isoCode}": { "get": { "tags": [ - "Media" + "Language" ], - "operationId": "GetMediaById", + "operationId": "GetLanguageByIsoCode", "parameters": [ { - "name": "id", + "name": "isoCode", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MediaResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } } } }, - "404": { - "description": "Not Found", + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/LanguageResponseModel" } ] } @@ -16890,9 +16365,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -16903,36 +16375,20 @@ }, "delete": { "tags": [ - "Media" + "Language" ], - "operationId": "DeleteMediaById", + "operationId": "DeleteLanguageByIsoCode", "parameters": [ { - "name": "id", + "name": "isoCode", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "400": { "description": "Bad Request", "headers": { @@ -16985,6 +16441,21 @@ } } }, + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -17012,17 +16483,16 @@ }, "put": { "tags": [ - "Media" + "Language" ], - "operationId": "PutMediaById", + "operationId": "PutLanguageByIsoCode", "parameters": [ { - "name": "id", + "name": "isoCode", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], @@ -17032,7 +16502,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMediaRequestModel" + "$ref": "#/components/schemas/UpdateLanguageRequestModel" } ] } @@ -17041,7 +16511,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMediaRequestModel" + "$ref": "#/components/schemas/UpdateLanguageRequestModel" } ] } @@ -17050,7 +16520,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMediaRequestModel" + "$ref": "#/components/schemas/UpdateLanguageRequestModel" } ] } @@ -17058,8 +16528,8 @@ } }, "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -17071,6 +16541,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "400": { @@ -17099,8 +16580,8 @@ } } }, - "404": { - "description": "Not Found", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -17112,17 +16593,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "401": { @@ -17151,48 +16621,24 @@ ] } }, - "/umbraco/management/api/v1/media/{id}/audit-log": { + "/umbraco/management/api/v1/log-viewer/level": { "get": { "tags": [ - "Media" + "Log Viewer" ], - "operationId": "GetMediaByIdAuditLog", + "operationId": "GetLogViewerLevel", "parameters": [ { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "orderDirection", + "name": "skip", "in": "query", "schema": { - "$ref": "#/components/schemas/DirectionModel" + "type": "integer", + "format": "int32", + "default": 0 } }, { - "name": "sinceDate", - "in": "query", - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", + "name": "take", "in": "query", "schema": { "type": "integer", @@ -17209,7 +16655,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedAuditLogResponseModel" + "$ref": "#/components/schemas/PagedLoggerResponseModel" } ] } @@ -17230,90 +16676,53 @@ ] } }, - "/umbraco/management/api/v1/media/{id}/move": { - "put": { + "/umbraco/management/api/v1/log-viewer/level-count": { + "get": { "tags": [ - "Media" + "Log Viewer" ], - "operationId": "PutMediaByIdMove", + "operationId": "GetLogViewerLevelCount", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "startDate", + "in": "query", "schema": { "type": "string", - "format": "uuid" + "format": "date-time" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MoveMediaRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MoveMediaRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MoveMediaRequestModel" - } - ] - } + }, + { + "name": "endDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" } } - }, + ], "responses": { - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "400": { + "description": "Bad Request", + "content": { + "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] } } } }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/LogLevelCountsReponseModel" } ] } @@ -17324,19 +16733,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -17346,85 +16743,81 @@ ] } }, - "/umbraco/management/api/v1/media/{id}/move-to-recycle-bin": { - "put": { + "/umbraco/management/api/v1/log-viewer/log": { + "get": { "tags": [ - "Media" + "Log Viewer" ], - "operationId": "PutMediaByIdMoveToRecycleBin", + "operationId": "GetLogViewerLog", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "orderDirection", + "in": "query", + "schema": { + "$ref": "#/components/schemas/DirectionModel" + } + }, + { + "name": "filterExpression", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "logLevel", + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogLevelModel" + } + } + }, + { + "name": "startDate", + "in": "query", "schema": { "type": "string", - "format": "uuid" + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" } } ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedLogMessageResponseModel" } ] } @@ -17435,19 +16828,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -17457,22 +16838,13 @@ ] } }, - "/umbraco/management/api/v1/media/{id}/referenced-by": { + "/umbraco/management/api/v1/log-viewer/message-template": { "get": { "tags": [ - "Media" + "Log Viewer" ], - "operationId": "GetMediaByIdReferencedBy", + "operationId": "GetLogViewerMessageTemplate", "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, { "name": "skip", "in": "query", @@ -17488,33 +16860,49 @@ "schema": { "type": "integer", "format": "int32", - "default": 20 + "default": 100 + } + }, + { + "name": "startDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" } } ], "responses": { - "200": { - "description": "OK", + "400": { + "description": "Bad Request", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedIReferenceResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } } } }, - "404": { - "description": "Not Found", + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedLogTemplateResponseModel" } ] } @@ -17535,22 +16923,13 @@ ] } }, - "/umbraco/management/api/v1/media/{id}/referenced-descendants": { + "/umbraco/management/api/v1/log-viewer/saved-search": { "get": { "tags": [ - "Media" + "Log Viewer" ], - "operationId": "GetMediaByIdReferencedDescendants", + "operationId": "GetLogViewerSavedSearch", "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, { "name": "skip", "in": "query", @@ -17566,7 +16945,7 @@ "schema": { "type": "integer", "format": "int32", - "default": 20 + "default": 100 } } ], @@ -17578,21 +16957,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedReferenceByIdModel" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedSavedLogSearchResponseModel" } ] } @@ -17611,32 +16976,19 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/media/{id}/validate": { - "put": { + }, + "post": { "tags": [ - "Media" - ], - "operationId": "PutMediaByIdValidate", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Log Viewer" ], + "operationId": "PostLogViewerSavedSearch", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMediaRequestModel" + "$ref": "#/components/schemas/SavedLogSearchRequestModel" } ] } @@ -17645,7 +16997,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMediaRequestModel" + "$ref": "#/components/schemas/SavedLogSearchRequestModel" } ] } @@ -17654,7 +17006,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMediaRequestModel" + "$ref": "#/components/schemas/SavedLogSearchRequestModel" } ] } @@ -17662,21 +17014,6 @@ } }, "responses": { - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "400": { "description": "Bad Request", "headers": { @@ -17703,9 +17040,24 @@ } } }, - "404": { - "description": "Not Found", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -17716,17 +17068,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "401": { @@ -17755,45 +17096,37 @@ ] } }, - "/umbraco/management/api/v1/media/are-referenced": { + "/umbraco/management/api/v1/log-viewer/saved-search/{name}": { "get": { "tags": [ - "Media" + "Log Viewer" ], - "operationId": "GetMediaAreReferenced", + "operationId": "GetLogViewerSavedSearchByName", "parameters": [ { - "name": "id", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", + "name": "name", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 20 + "type": "string" } } ], "responses": { + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "200": { "description": "OK", "content": { @@ -17801,7 +17134,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedReferenceByIdModel" + "$ref": "#/components/schemas/SavedLogSearchResponseModel" } ] } @@ -17820,82 +17153,49 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/media/configuration": { - "get": { + }, + "delete": { "tags": [ - "Media" + "Log Viewer" + ], + "operationId": "DeleteLogViewerSavedSearchByName", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "operationId": "GetMediaConfiguration", "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MediaConfigurationResponseModel" + "$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" - } - }, - "deprecated": true, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/media/sort": { - "put": { - "tags": [ - "Media" - ], - "operationId": "PutMediaSort", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/SortingRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/SortingRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/SortingRequestModel" - } - ] - } - } - } - }, - "responses": { "200": { "description": "OK", "headers": { @@ -17911,8 +17211,11 @@ } } }, - "400": { - "description": "Bad Request", + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -17924,7 +17227,43 @@ "nullable": true } } - }, + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/log-viewer/validate-logs-size": { + "get": { + "tags": [ + "Log Viewer" + ], + "operationId": "GetLogViewerValidateLogsSize", + "parameters": [ + { + "name": "startDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", "content": { "application/json": { "schema": { @@ -17937,28 +17276,43 @@ } } }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "200": { + "description": "OK" + }, + "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/manifest/manifest": { + "get": { + "tags": [ + "Manifest" + ], + "operationId": "GetManifestManifest", + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ManifestResponseModel" + } + ] + } } } } @@ -17967,19 +17321,45 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/manifest/manifest/private": { + "get": { + "tags": [ + "Manifest" + ], + "operationId": "GetManifestManifestPrivate", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/ManifestResponseModel" + } + ] + } } } } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -17989,12 +17369,39 @@ ] } }, - "/umbraco/management/api/v1/media/urls": { + "/umbraco/management/api/v1/manifest/manifest/public": { "get": { "tags": [ - "Media" + "Manifest" ], - "operationId": "GetMediaUrls", + "operationId": "GetManifestManifestPublic", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ManifestResponseModel" + } + ] + } + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/item/media-type": { + "get": { + "tags": [ + "Media Type" + ], + "operationId": "GetItemMediaType", "parameters": [ { "name": "id", @@ -18019,7 +17426,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/MediaUrlInfoResponseModel" + "$ref": "#/components/schemas/MediaTypeItemResponseModel" } ] } @@ -18029,9 +17436,65 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/item/media-type/allowed": { + "get": { + "tags": [ + "Media Type" + ], + "operationId": "GetItemMediaTypeAllowed", + "parameters": [ + { + "name": "fileExtension", + "in": "query", + "schema": { + "type": "string" + } }, - "403": { - "description": "The authenticated user does not have access to this resource" + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedModelMediaTypeItemResponseModel" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" } }, "security": [ @@ -18041,19 +17504,130 @@ ] } }, - "/umbraco/management/api/v1/media/validate": { + "/umbraco/management/api/v1/item/media-type/folders": { + "get": { + "tags": [ + "Media Type" + ], + "operationId": "GetItemMediaTypeFolders", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedModelMediaTypeItemResponseModel" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/item/media-type/search": { + "get": { + "tags": [ + "Media Type" + ], + "operationId": "GetItemMediaTypeSearch", + "parameters": [ + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedModelMediaTypeItemResponseModel" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/media-type": { "post": { "tags": [ - "Media" + "Media Type" ], - "operationId": "PostMediaValidate", + "operationId": "PostMediaType", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMediaRequestModel" + "$ref": "#/components/schemas/CreateMediaTypeRequestModel" } ] } @@ -18062,7 +17636,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMediaRequestModel" + "$ref": "#/components/schemas/CreateMediaTypeRequestModel" } ] } @@ -18071,7 +17645,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMediaRequestModel" + "$ref": "#/components/schemas/CreateMediaTypeRequestModel" } ] } @@ -18079,9 +17653,24 @@ } }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -18172,42 +17761,40 @@ ] } }, - "/umbraco/management/api/v1/recycle-bin/media": { - "delete": { + "/umbraco/management/api/v1/media-type/{id}": { + "get": { "tags": [ - "Media" + "Media Type" + ], + "operationId": "GetMediaTypeById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "DeleteRecycleBinMedia", "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "content": { + "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTypeResponseModel" + } + ] } } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { @@ -18224,19 +17811,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -18244,14 +17819,12 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/recycle-bin/media/{id}": { + }, "delete": { "tags": [ - "Media" + "Media Type" ], - "operationId": "DeleteRecycleBinMediaById", + "operationId": "DeleteMediaTypeById", "parameters": [ { "name": "id", @@ -18279,32 +17852,6 @@ } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, "404": { "description": "Not Found", "headers": { @@ -18355,88 +17902,12 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/recycle-bin/media/{id}/original-parent": { - "get": { - "tags": [ - "Media" - ], - "operationId": "GetRecycleBinMediaByIdOriginalParent", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "400": { - "description": "Bad Request", - "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/recycle-bin/media/{id}/restore": { + }, "put": { "tags": [ - "Media" + "Media Type" ], - "operationId": "PutRecycleBinMediaByIdRestore", + "operationId": "PutMediaTypeById", "parameters": [ { "name": "id", @@ -18454,7 +17925,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MoveMediaRequestModel" + "$ref": "#/components/schemas/UpdateMediaTypeRequestModel" } ] } @@ -18463,7 +17934,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MoveMediaRequestModel" + "$ref": "#/components/schemas/UpdateMediaTypeRequestModel" } ] } @@ -18472,7 +17943,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MoveMediaRequestModel" + "$ref": "#/components/schemas/UpdateMediaTypeRequestModel" } ] } @@ -18495,8 +17966,8 @@ } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -18521,8 +17992,8 @@ } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -18573,15 +18044,24 @@ ] } }, - "/umbraco/management/api/v1/recycle-bin/media/children": { + "/umbraco/management/api/v1/media-type/{id}/allowed-children": { "get": { "tags": [ - "Media" + "Media Type" ], - "operationId": "GetRecycleBinMediaChildren", + "operationId": "GetMediaTypeByIdAllowedChildren", "parameters": [ { - "name": "parentId", + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "parentContentKey", "in": "query", "schema": { "type": "string", @@ -18615,62 +18095,21 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedMediaRecycleBinItemResponseModel" + "$ref": "#/components/schemas/PagedAllowedMediaTypeModel" } ] } } } }, - "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/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", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedIReferenceResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -18691,110 +18130,63 @@ ] } }, - "/umbraco/management/api/v1/recycle-bin/media/root": { + "/umbraco/management/api/v1/media-type/{id}/composition-references": { "get": { "tags": [ - "Media" + "Media Type" ], - "operationId": "GetRecycleBinMediaRoot", + "operationId": "GetMediaTypeByIdCompositionReferences", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "type": "string", + "format": "uuid" } } ], "responses": { "200": { "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTypeCompositionResponseModel" + } + ] + } + } + } + } + }, + "400": { + "description": "Bad Request", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedMediaRecycleBinItemResponseModel" + "$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/recycle-bin/media/siblings": { - "get": { - "tags": [ - "Media" - ], - "operationId": "GetRecycleBinMediaSiblings", - "parameters": [ - { - "name": "target", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "before", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "after", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "dataTypeId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SubsetMediaRecycleBinItemResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -18815,105 +18207,131 @@ ] } }, - "/umbraco/management/api/v1/tree/media/ancestors": { - "get": { + "/umbraco/management/api/v1/media-type/{id}/copy": { + "post": { "tags": [ - "Media" + "Media Type" ], - "operationId": "GetTreeMediaAncestors", + "operationId": "PostMediaTypeByIdCopy", "parameters": [ { - "name": "descendantId", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { "type": "string", "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CopyMediaTypeRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CopyMediaTypeRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CopyMediaTypeRequestModel" + } + ] + } + } + } + }, "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTreeItemResponseModel" - } - ] - } + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } } }, - "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/tree/media/children": { - "get": { - "tags": [ - "Media" - ], - "operationId": "GetTreeMediaChildren", - "parameters": [ - { - "name": "parentId", - "in": "query", - "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": 100 + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, - { - "name": "dataTypeId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedMediaTreeItemResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -18924,7 +18342,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -18934,34 +18364,17 @@ ] } }, - "/umbraco/management/api/v1/tree/media/root": { + "/umbraco/management/api/v1/media-type/{id}/export": { "get": { "tags": [ - "Media" + "Media Type" ], - "operationId": "GetTreeMediaRoot", + "operationId": "GetMediaTypeByIdExport", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - }, - { - "name": "dataTypeId", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { "type": "string", "format": "uuid" @@ -18976,7 +18389,22 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedMediaTreeItemResponseModel" + "type": "string", + "format": "binary" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -18997,190 +18425,30 @@ ] } }, - "/umbraco/management/api/v1/tree/media/siblings": { - "get": { + "/umbraco/management/api/v1/media-type/{id}/import": { + "put": { "tags": [ - "Media" + "Media Type" ], - "operationId": "GetTreeMediaSiblings", + "operationId": "PutMediaTypeByIdImport", "parameters": [ { - "name": "target", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "before", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "after", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "dataTypeId", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { "type": "string", "format": "uuid" } } ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/SubsetMediaTreeItemResponseModel" - } - ] - } - } - } - }, - "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/item/member-group": { - "get": { - "tags": [ - "Member Group" - ], - "operationId": "GetItemMemberGroup", - "parameters": [ - { - "name": "id", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MemberGroupItemResponseModel" - } - ] - } - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/member-group": { - "get": { - "tags": [ - "Member Group" - ], - "operationId": "GetMemberGroup", - "parameters": [ - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedMemberGroupResponseModel" - } - ] - } - } - } - }, - "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": [ ] - } - ] - }, - "post": { - "tags": [ - "Member Group" - ], - "operationId": "PostMemberGroup", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMemberGroupRequestModel" + "$ref": "#/components/schemas/ImportMediaTypeRequestModel" } ] } @@ -19189,7 +18457,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMemberGroupRequestModel" + "$ref": "#/components/schemas/ImportMediaTypeRequestModel" } ] } @@ -19198,7 +18466,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMemberGroupRequestModel" + "$ref": "#/components/schemas/ImportMediaTypeRequestModel" } ] } @@ -19206,24 +18474,9 @@ } }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -19236,8 +18489,8 @@ } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -19262,97 +18515,6 @@ } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/member-group/{id}": { - "get": { - "tags": [ - "Member Group" - ], - "operationId": "GetMemberGroupById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MemberGroupResponseModel" - } - ] - } - } - } - }, - "404": { - "description": "Not Found" - }, - "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": [ ] - } - ] - }, - "delete": { - "tags": [ - "Member Group" - ], - "operationId": "DeleteMemberGroupById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { "400": { "description": "Bad Request", "headers": { @@ -19379,47 +18541,6 @@ } } }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -19444,12 +18565,14 @@ "Backoffice-User": [ ] } ] - }, + } + }, + "/umbraco/management/api/v1/media-type/{id}/move": { "put": { "tags": [ - "Member Group" + "Media Type" ], - "operationId": "PutMemberGroupById", + "operationId": "PutMediaTypeByIdMove", "parameters": [ { "name": "id", @@ -19467,7 +18590,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberGroupRequestModel" + "$ref": "#/components/schemas/MoveMediaTypeRequestModel" } ] } @@ -19476,7 +18599,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberGroupRequestModel" + "$ref": "#/components/schemas/MoveMediaTypeRequestModel" } ] } @@ -19485,7 +18608,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberGroupRequestModel" + "$ref": "#/components/schemas/MoveMediaTypeRequestModel" } ] } @@ -19586,12 +18709,12 @@ ] } }, - "/umbraco/management/api/v1/tree/member-group/root": { + "/umbraco/management/api/v1/media-type/allowed-at-root": { "get": { "tags": [ - "Member Group" + "Media Type" ], - "operationId": "GetTreeMemberGroupRoot", + "operationId": "GetMediaTypeAllowedAtRoot", "parameters": [ { "name": "skip", @@ -19620,7 +18743,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedNamedEntityTreeItemResponseModel" + "$ref": "#/components/schemas/PagedAllowedMediaTypeModel" } ] } @@ -19641,29 +18764,58 @@ ] } }, - "/umbraco/management/api/v1/item/member-type": { - "get": { + "/umbraco/management/api/v1/media-type/available-compositions": { + "post": { "tags": [ - "Member Type" + "Media Type" ], - "operationId": "GetItemMemberType", - "parameters": [ - { - "name": "id", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" + "operationId": "PostMediaTypeAvailableCompositions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTypeCompositionRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTypeCompositionRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTypeCompositionRequestModel" + } + ] } } } - ], + }, "responses": { "200": { "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { @@ -19671,7 +18823,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/MemberTypeItemResponseModel" + "$ref": "#/components/schemas/AvailableMediaTypeCompositionResponseModel" } ] } @@ -19681,6 +18833,21 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -19690,39 +18857,12 @@ ] } }, - "/umbraco/management/api/v1/item/member-type/search": { + "/umbraco/management/api/v1/media-type/configuration": { "get": { "tags": [ - "Member Type" - ], - "operationId": "GetItemMemberTypeSearch", - "parameters": [ - { - "name": "query", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } + "Media Type" ], + "operationId": "GetMediaTypeConfiguration", "responses": { "200": { "description": "OK", @@ -19731,7 +18871,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedModelMemberTypeItemResponseModel" + "$ref": "#/components/schemas/MediaTypeConfigurationResponseModel" } ] } @@ -19740,6 +18880,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -19749,19 +18892,19 @@ ] } }, - "/umbraco/management/api/v1/member-type": { + "/umbraco/management/api/v1/media-type/folder": { "post": { "tags": [ - "Member Type" + "Media Type" ], - "operationId": "PostMemberType", + "operationId": "PostMediaTypeFolder", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMemberTypeRequestModel" + "$ref": "#/components/schemas/CreateFolderRequestModel" } ] } @@ -19770,7 +18913,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMemberTypeRequestModel" + "$ref": "#/components/schemas/CreateFolderRequestModel" } ] } @@ -19779,7 +18922,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMemberTypeRequestModel" + "$ref": "#/components/schemas/CreateFolderRequestModel" } ] } @@ -19895,12 +19038,12 @@ ] } }, - "/umbraco/management/api/v1/member-type/{id}": { + "/umbraco/management/api/v1/media-type/folder/{id}": { "get": { "tags": [ - "Member Type" + "Media Type" ], - "operationId": "GetMemberTypeById", + "operationId": "GetMediaTypeFolderById", "parameters": [ { "name": "id", @@ -19920,7 +19063,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MemberTypeResponseModel" + "$ref": "#/components/schemas/FolderResponseModel" } ] } @@ -19956,9 +19099,9 @@ }, "delete": { "tags": [ - "Member Type" + "Media Type" ], - "operationId": "DeleteMemberTypeById", + "operationId": "DeleteMediaTypeFolderById", "parameters": [ { "name": "id", @@ -19986,6 +19129,32 @@ } } }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "404": { "description": "Not Found", "headers": { @@ -20039,9 +19208,9 @@ }, "put": { "tags": [ - "Member Type" + "Media Type" ], - "operationId": "PutMemberTypeById", + "operationId": "PutMediaTypeFolderById", "parameters": [ { "name": "id", @@ -20059,7 +19228,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberTypeRequestModel" + "$ref": "#/components/schemas/UpdateFolderResponseModel" } ] } @@ -20068,7 +19237,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberTypeRequestModel" + "$ref": "#/components/schemas/UpdateFolderResponseModel" } ] } @@ -20077,7 +19246,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberTypeRequestModel" + "$ref": "#/components/schemas/UpdateFolderResponseModel" } ] } @@ -20178,107 +19347,19 @@ ] } }, - "/umbraco/management/api/v1/member-type/{id}/composition-references": { - "get": { - "tags": [ - "Member Type" - ], - "operationId": "GetMemberTypeByIdCompositionReferences", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MemberTypeCompositionResponseModel" - } - ] - } - } - } - } - }, - "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/member-type/{id}/copy": { + "/umbraco/management/api/v1/media-type/import": { "post": { "tags": [ - "Member Type" - ], - "operationId": "PostMemberTypeByIdCopy", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Media Type" ], + "operationId": "PostMediaTypeImport", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CopyMemberTypeRequestModel" + "$ref": "#/components/schemas/ImportMediaTypeRequestModel" } ] } @@ -20287,7 +19368,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CopyMemberTypeRequestModel" + "$ref": "#/components/schemas/ImportMediaTypeRequestModel" } ] } @@ -20296,7 +19377,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CopyMemberTypeRequestModel" + "$ref": "#/components/schemas/ImportMediaTypeRequestModel" } ] } @@ -20334,8 +19415,8 @@ } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -20360,8 +19441,8 @@ } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -20412,17 +19493,16 @@ ] } }, - "/umbraco/management/api/v1/member-type/{id}/export": { + "/umbraco/management/api/v1/tree/media-type/ancestors": { "get": { "tags": [ - "Member Type" + "Media Type" ], - "operationId": "GetMemberTypeByIdExport", + "operationId": "GetTreeMediaTypeAncestors", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "descendantId", + "in": "query", "schema": { "type": "string", "format": "uuid" @@ -20435,24 +19515,83 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "string", - "format": "binary" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTypeTreeItemResponseModel" + } + ] + } } } } }, - "404": { - "description": "Not Found", + "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/tree/media-type/children": { + "get": { + "tags": [ + "Media Type" + ], + "operationId": "GetTreeMediaTypeChildren", + "parameters": [ + { + "name": "parentId", + "in": "query", + "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": 100 + } + }, + { + "name": "foldersOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedMediaTypeTreeItemResponseModel" } ] } @@ -20473,116 +19612,118 @@ ] } }, - "/umbraco/management/api/v1/member-type/{id}/import": { - "put": { + "/umbraco/management/api/v1/tree/media-type/root": { + "get": { "tags": [ - "Member Type" + "Media Type" ], - "operationId": "PutMemberTypeByIdImport", + "operationId": "GetTreeMediaTypeRoot", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "skip", + "in": "query", "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int32", + "default": 0 } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ImportMemberTypeRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ImportMemberTypeRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ImportMemberTypeRequestModel" - } - ] - } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "foldersOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": false } } - }, + ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedMediaTypeTreeItemResponseModel" } ] } } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "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/tree/media-type/siblings": { + "get": { + "tags": [ + "Media Type" + ], + "operationId": "GetTreeMediaTypeSiblings", + "parameters": [ + { + "name": "target", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "foldersOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/SubsetMediaTypeTreeItemResponseModel" } ] } @@ -20593,19 +19734,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -20615,84 +19744,87 @@ ] } }, - "/umbraco/management/api/v1/member-type/{id}/move": { - "put": { + "/umbraco/management/api/v1/collection/media": { + "get": { "tags": [ - "Member Type" + "Media" ], - "operationId": "PutMemberTypeByIdMove", + "operationId": "GetCollectionMedia", "parameters": [ { "name": "id", - "in": "path", - "required": true, + "in": "query", "schema": { "type": "string", "format": "uuid" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MoveMemberTypeRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MoveMemberTypeRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MoveMemberTypeRequestModel" - } - ] - } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "orderBy", + "in": "query", + "schema": { + "type": "string", + "default": "updateDate" + } + }, + { + "name": "orderDirection", + "in": "query", + "schema": { + "$ref": "#/components/schemas/DirectionModel" + } + }, + { + "name": "filter", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } - }, + ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "content": { + "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/PagedMediaCollectionResponseModel" + } + ] } } } }, "400": { "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { @@ -20707,20 +19839,8 @@ }, "404": { "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { + "content": { + "application/json": { "schema": { "oneOf": [ { @@ -20735,19 +19855,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -20757,58 +19865,29 @@ ] } }, - "/umbraco/management/api/v1/member-type/available-compositions": { - "post": { + "/umbraco/management/api/v1/item/media": { + "get": { "tags": [ - "Member Type" + "Media" ], - "operationId": "PostMemberTypeAvailableCompositions", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MemberTypeCompositionRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MemberTypeCompositionRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/MemberTypeCompositionRequestModel" - } - ] + "operationId": "GetItemMedia", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" } } } - }, + ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { @@ -20816,7 +19895,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/AvailableMemberTypeCompositionResponseModel" + "$ref": "#/components/schemas/MediaItemResponseModel" } ] } @@ -20826,21 +19905,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } } }, "security": [ @@ -20850,12 +19914,80 @@ ] } }, - "/umbraco/management/api/v1/member-type/configuration": { + "/umbraco/management/api/v1/item/media/search": { "get": { "tags": [ - "Member Type" + "Media" + ], + "operationId": "GetItemMediaSearch", + "parameters": [ + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "trashed", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "culture", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "parentId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "allowedMediaTypes", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "GetMemberTypeConfiguration", "responses": { "200": { "description": "OK", @@ -20864,7 +19996,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MemberTypeConfigurationResponseModel" + "$ref": "#/components/schemas/PagedModelMediaItemResponseModel" } ] } @@ -20873,9 +20005,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -20885,19 +20014,19 @@ ] } }, - "/umbraco/management/api/v1/member-type/folder": { + "/umbraco/management/api/v1/media": { "post": { "tags": [ - "Member Type" + "Media" ], - "operationId": "PostMemberTypeFolder", + "operationId": "PostMedia", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateFolderRequestModel" + "$ref": "#/components/schemas/CreateMediaRequestModel" } ] } @@ -20906,7 +20035,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateFolderRequestModel" + "$ref": "#/components/schemas/CreateMediaRequestModel" } ] } @@ -20915,7 +20044,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateFolderRequestModel" + "$ref": "#/components/schemas/CreateMediaRequestModel" } ] } @@ -21031,12 +20160,12 @@ ] } }, - "/umbraco/management/api/v1/member-type/folder/{id}": { + "/umbraco/management/api/v1/media/{id}": { "get": { "tags": [ - "Member Type" + "Media" ], - "operationId": "GetMemberTypeFolderById", + "operationId": "GetMediaById", "parameters": [ { "name": "id", @@ -21056,7 +20185,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/FolderResponseModel" + "$ref": "#/components/schemas/MediaResponseModel" } ] } @@ -21092,9 +20221,9 @@ }, "delete": { "tags": [ - "Member Type" + "Media" ], - "operationId": "DeleteMemberTypeFolderById", + "operationId": "DeleteMediaById", "parameters": [ { "name": "id", @@ -21201,9 +20330,9 @@ }, "put": { "tags": [ - "Member Type" + "Media" ], - "operationId": "PutMemberTypeFolderById", + "operationId": "PutMediaById", "parameters": [ { "name": "id", @@ -21221,7 +20350,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateFolderResponseModel" + "$ref": "#/components/schemas/UpdateMediaRequestModel" } ] } @@ -21230,7 +20359,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateFolderResponseModel" + "$ref": "#/components/schemas/UpdateMediaRequestModel" } ] } @@ -21239,7 +20368,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateFolderResponseModel" + "$ref": "#/components/schemas/UpdateMediaRequestModel" } ] } @@ -21340,19 +20469,109 @@ ] } }, - "/umbraco/management/api/v1/member-type/import": { - "post": { + "/umbraco/management/api/v1/media/{id}/audit-log": { + "get": { "tags": [ - "Member Type" + "Media" + ], + "operationId": "GetMediaByIdAuditLog", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "orderDirection", + "in": "query", + "schema": { + "$ref": "#/components/schemas/DirectionModel" + } + }, + { + "name": "sinceDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedAuditLogResponseModel" + } + ] + } + } + } + }, + "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/media/{id}/move": { + "put": { + "tags": [ + "Media" + ], + "operationId": "PutMediaByIdMove", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "PostMemberTypeImport", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ImportMemberTypeRequestModel" + "$ref": "#/components/schemas/MoveMediaRequestModel" } ] } @@ -21361,7 +20580,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ImportMemberTypeRequestModel" + "$ref": "#/components/schemas/MoveMediaRequestModel" } ] } @@ -21370,7 +20589,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ImportMemberTypeRequestModel" + "$ref": "#/components/schemas/MoveMediaRequestModel" } ] } @@ -21378,24 +20597,9 @@ } }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -21434,32 +20638,6 @@ } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -21486,16 +20664,17 @@ ] } }, - "/umbraco/management/api/v1/tree/member-type/ancestors": { - "get": { + "/umbraco/management/api/v1/media/{id}/move-to-recycle-bin": { + "put": { "tags": [ - "Member Type" + "Media" ], - "operationId": "GetTreeMemberTypeAncestors", + "operationId": "PutMediaByIdMoveToRecycleBin", "parameters": [ { - "name": "descendantId", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { "type": "string", "format": "uuid" @@ -21505,86 +20684,65 @@ "responses": { "200": { "description": "OK", - "content": { - "application/json": { + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MemberTypeTreeItemResponseModel" - } - ] - } + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } } }, - "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/tree/member-type/children": { - "get": { - "tags": [ - "Member Type" - ], - "operationId": "GetTreeMemberTypeChildren", - "parameters": [ - { - "name": "parentId", - "in": "query", - "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": 100 + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, - { - "name": "foldersOnly", - "in": "query", - "schema": { - "type": "boolean", - "default": false - } - } - ], - "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedMemberTypeTreeItemResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -21595,7 +20753,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -21605,13 +20775,22 @@ ] } }, - "/umbraco/management/api/v1/tree/member-type/root": { + "/umbraco/management/api/v1/media/{id}/referenced-by": { "get": { "tags": [ - "Member Type" + "Media" ], - "operationId": "GetTreeMemberTypeRoot", + "operationId": "GetMediaByIdReferencedBy", "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "skip", "in": "query", @@ -21627,15 +20806,7 @@ "schema": { "type": "integer", "format": "int32", - "default": 100 - } - }, - { - "name": "foldersOnly", - "in": "query", - "schema": { - "type": "boolean", - "default": false + "default": 20 } } ], @@ -21647,76 +20818,21 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedMemberTypeTreeItemResponseModel" + "$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/tree/member-type/siblings": { - "get": { - "tags": [ - "Member Type" - ], - "operationId": "GetTreeMemberTypeSiblings", - "parameters": [ - { - "name": "target", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "before", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "after", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "foldersOnly", - "in": "query", - "schema": { - "type": "boolean", - "default": false - } - } - ], - "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SubsetMemberTypeTreeItemResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -21737,64 +20853,22 @@ ] } }, - "/umbraco/management/api/v1/filter/member": { + "/umbraco/management/api/v1/media/{id}/referenced-descendants": { "get": { "tags": [ - "Member" + "Media" ], - "operationId": "GetFilterMember", + "operationId": "GetMediaByIdReferencedDescendants", "parameters": [ { - "name": "memberTypeId", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { "type": "string", "format": "uuid" } }, - { - "name": "memberGroupName", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "isApproved", - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isLockedOut", - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "orderBy", - "in": "query", - "schema": { - "type": "string", - "default": "username" - } - }, - { - "name": "orderDirection", - "in": "query", - "schema": { - "$ref": "#/components/schemas/DirectionModel" - } - }, - { - "name": "filter", - "in": "query", - "schema": { - "type": "string" - } - }, { "name": "skip", "in": "query", @@ -21810,7 +20884,7 @@ "schema": { "type": "integer", "format": "int32", - "default": 100 + "default": 20 } } ], @@ -21822,7 +20896,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedMemberResponseModel" + "$ref": "#/components/schemas/PagedReferenceByIdModel" } ] } @@ -21857,138 +20931,30 @@ ] } }, - "/umbraco/management/api/v1/item/member": { - "get": { + "/umbraco/management/api/v1/media/{id}/validate": { + "put": { "tags": [ - "Member" + "Media" ], - "operationId": "GetItemMember", + "operationId": "PutMediaByIdValidate", "parameters": [ { "name": "id", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MemberItemResponseModel" - } - ] - } - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/item/member/search": { - "get": { - "tags": [ - "Member" - ], - "operationId": "GetItemMemberSearch", - "parameters": [ - { - "name": "query", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - }, - { - "name": "allowedMemberTypes", - "in": "query", + "in": "path", + "required": true, "schema": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedModelMemberItemResponseModel" - } - ] - } - } + "type": "string", + "format": "uuid" } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] } - ] - } - }, - "/umbraco/management/api/v1/member": { - "post": { - "tags": [ - "Member" ], - "operationId": "PostMember", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMemberRequestModel" + "$ref": "#/components/schemas/UpdateMediaRequestModel" } ] } @@ -21997,7 +20963,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMemberRequestModel" + "$ref": "#/components/schemas/UpdateMediaRequestModel" } ] } @@ -22006,7 +20972,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateMemberRequestModel" + "$ref": "#/components/schemas/UpdateMediaRequestModel" } ] } @@ -22014,24 +20980,9 @@ } }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -22044,8 +20995,8 @@ } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -22070,8 +21021,8 @@ } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -22122,20 +21073,41 @@ ] } }, - "/umbraco/management/api/v1/member/{id}": { + "/umbraco/management/api/v1/media/are-referenced": { "get": { "tags": [ - "Member" + "Media" ], - "operationId": "GetMemberById", + "operationId": "GetMediaAreReferenced", "parameters": [ { "name": "id", - "in": "path", - "required": true, + "in": "query", "schema": { - "type": "string", - "format": "uuid" + "uniqueItems": true, + "type": "array", + "items": { + "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 } } ], @@ -22147,21 +21119,42 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MemberResponseModel" + "$ref": "#/components/schemas/PagedReferenceByIdModel" } ] } } } }, - "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/media/configuration": { + "get": { + "tags": [ + "Media" + ], + "operationId": "GetMediaConfiguration", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaConfigurationResponseModel" } ] } @@ -22175,28 +21168,51 @@ "description": "The authenticated user does not have access to this resource" } }, + "deprecated": true, "security": [ { "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/media/sort": { + "put": { "tags": [ - "Member" + "Media" ], - "operationId": "DeleteMemberById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "operationId": "PutMediaSort", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SortingRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SortingRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SortingRequestModel" + } + ] + } } } - ], + }, "responses": { "200": { "description": "OK", @@ -22289,30 +21305,73 @@ "Backoffice-User": [ ] } ] - }, - "put": { + } + }, + "/umbraco/management/api/v1/media/urls": { + "get": { "tags": [ - "Member" + "Media" ], - "operationId": "PutMemberById", + "operationId": "GetMediaUrls", "parameters": [ { "name": "id", - "in": "path", - "required": true, + "in": "query", "schema": { - "type": "string", - "format": "uuid" + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaUrlInfoResponseModel" + } + ] + } + } + } } + }, + "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/media/validate": { + "post": { + "tags": [ + "Media" ], + "operationId": "PostMediaValidate", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberRequestModel" + "$ref": "#/components/schemas/CreateMediaRequestModel" } ] } @@ -22321,7 +21380,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberRequestModel" + "$ref": "#/components/schemas/CreateMediaRequestModel" } ] } @@ -22330,7 +21389,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberRequestModel" + "$ref": "#/components/schemas/CreateMediaRequestModel" } ] } @@ -22431,12 +21490,86 @@ ] } }, - "/umbraco/management/api/v1/member/{id}/referenced-by": { - "get": { + "/umbraco/management/api/v1/recycle-bin/media": { + "delete": { "tags": [ - "Member" + "Media" ], - "operationId": "GetMemberByIdReferencedBy", + "operationId": "DeleteRecycleBinMedia", + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/recycle-bin/media/{id}": { + "delete": { + "tags": [ + "Media" + ], + "operationId": "DeleteRecycleBinMediaById", "parameters": [ { "name": "id", @@ -22446,35 +21579,44 @@ "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedIReferenceResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -22483,6 +21625,18 @@ }, "404": { "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { @@ -22499,7 +21653,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -22509,12 +21675,12 @@ ] } }, - "/umbraco/management/api/v1/member/{id}/referenced-descendants": { + "/umbraco/management/api/v1/recycle-bin/media/{id}/original-parent": { "get": { "tags": [ - "Member" + "Media" ], - "operationId": "GetMemberByIdReferencedDescendants", + "operationId": "GetRecycleBinMediaByIdOriginalParent", "parameters": [ { "name": "id", @@ -22524,24 +21690,6 @@ "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": { @@ -22552,7 +21700,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedReferenceByIdModel" + "$ref": "#/components/schemas/ReferenceByIdModel" } ] } @@ -22573,6 +21721,20 @@ } } }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -22587,12 +21749,12 @@ ] } }, - "/umbraco/management/api/v1/member/{id}/validate": { + "/umbraco/management/api/v1/recycle-bin/media/{id}/restore": { "put": { "tags": [ - "Member" + "Media" ], - "operationId": "PutMemberByIdValidate", + "operationId": "PutRecycleBinMediaByIdRestore", "parameters": [ { "name": "id", @@ -22610,7 +21772,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberRequestModel" + "$ref": "#/components/schemas/MoveMediaRequestModel" } ] } @@ -22619,7 +21781,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberRequestModel" + "$ref": "#/components/schemas/MoveMediaRequestModel" } ] } @@ -22628,7 +21790,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateMemberRequestModel" + "$ref": "#/components/schemas/MoveMediaRequestModel" } ] } @@ -22651,8 +21813,8 @@ } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -22677,8 +21839,8 @@ } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -22729,23 +21891,19 @@ ] } }, - "/umbraco/management/api/v1/member/are-referenced": { + "/umbraco/management/api/v1/recycle-bin/media/children": { "get": { "tags": [ - "Member" + "Media" ], - "operationId": "GetMemberAreReferenced", + "operationId": "GetRecycleBinMediaChildren", "parameters": [ { - "name": "id", + "name": "parentId", "in": "query", "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } + "type": "string", + "format": "uuid" } }, { @@ -22763,7 +21921,7 @@ "schema": { "type": "integer", "format": "int32", - "default": 20 + "default": 100 } } ], @@ -22775,7 +21933,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedReferenceByIdModel" + "$ref": "#/components/schemas/PagedMediaRecycleBinItemResponseModel" } ] } @@ -22796,12 +21954,32 @@ ] } }, - "/umbraco/management/api/v1/member/configuration": { + "/umbraco/management/api/v1/recycle-bin/media/referenced-by": { "get": { "tags": [ - "Member" + "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 + } + } ], - "operationId": "GetMemberConfiguration", "responses": { "200": { "description": "OK", @@ -22810,7 +21988,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/MemberConfigurationResponseModel" + "$ref": "#/components/schemas/PagedIReferenceResponseModel" } ] } @@ -22831,105 +22009,41 @@ ] } }, - "/umbraco/management/api/v1/member/validate": { - "post": { + "/umbraco/management/api/v1/recycle-bin/media/root": { + "get": { "tags": [ - "Member" + "Media" ], - "operationId": "PostMemberValidate", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateMemberRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateMemberRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateMemberRequestModel" - } - ] - } + "operationId": "GetRecycleBinMediaRoot", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } - }, + ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedMediaRecycleBinItemResponseModel" } ] } @@ -22940,19 +22054,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -22962,86 +22064,46 @@ ] } }, - "/umbraco/management/api/v1/models-builder/build": { - "post": { + "/umbraco/management/api/v1/recycle-bin/media/siblings": { + "get": { "tags": [ - "Models Builder" + "Media" ], - "operationId": "PostModelsBuilderBuild", - "responses": { - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } + "operationId": "GetRecycleBinMediaSiblings", + "parameters": [ + { + "name": "target", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" } }, - "428": { - "description": "Precondition Required", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } + { + "name": "before", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" } }, - "401": { - "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } + { + "name": "after", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" } - } - }, - "security": [ + }, { - "Backoffice-User": [ ] + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } } - ] - } - }, - "/umbraco/management/api/v1/models-builder/dashboard": { - "get": { - "tags": [ - "Models Builder" ], - "operationId": "GetModelsBuilderDashboard", "responses": { "200": { "description": "OK", @@ -23050,7 +22112,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ModelsBuilderResponseModel" + "$ref": "#/components/schemas/SubsetMediaRecycleBinItemResponseModel" } ] } @@ -23071,23 +22133,36 @@ ] } }, - "/umbraco/management/api/v1/models-builder/status": { + "/umbraco/management/api/v1/tree/media/ancestors": { "get": { "tags": [ - "Models Builder" + "Media" + ], + "operationId": "GetTreeMediaAncestors", + "parameters": [ + { + "name": "descendantId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "GetModelsBuilderStatus", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/OutOfDateStatusResponseModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTreeItemResponseModel" + } + ] + } } } } @@ -23106,12 +22181,48 @@ ] } }, - "/umbraco/management/api/v1/news-dashboard": { + "/umbraco/management/api/v1/tree/media/children": { "get": { "tags": [ - "News Dashboard" + "Media" + ], + "operationId": "GetTreeMediaChildren", + "parameters": [ + { + "name": "parentId", + "in": "query", + "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": 100 + } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "GetNewsDashboard", "responses": { "200": { "description": "OK", @@ -23120,7 +22231,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/NewsDashboardResponseModel" + "$ref": "#/components/schemas/PagedMediaTreeItemResponseModel" } ] } @@ -23129,6 +22240,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -23138,12 +22252,12 @@ ] } }, - "/umbraco/management/api/v1/object-types": { + "/umbraco/management/api/v1/tree/media/root": { "get": { "tags": [ - "Object Types" + "Media" ], - "operationId": "GetObjectTypes", + "operationId": "GetTreeMediaRoot", "parameters": [ { "name": "skip", @@ -23162,6 +22276,14 @@ "format": "int32", "default": 100 } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -23172,7 +22294,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedObjectTypeResponseModel" + "$ref": "#/components/schemas/PagedMediaTreeItemResponseModel" } ] } @@ -23181,6 +22303,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -23190,23 +22315,23 @@ ] } }, - "/umbraco/management/api/v1/oembed/query": { + "/umbraco/management/api/v1/tree/media/siblings": { "get": { "tags": [ - "oEmbed" + "Media" ], - "operationId": "GetOembedQuery", + "operationId": "GetTreeMediaSiblings", "parameters": [ { - "name": "url", + "name": "target", "in": "query", "schema": { "type": "string", - "format": "uri" + "format": "uuid" } }, { - "name": "maxWidth", + "name": "before", "in": "query", "schema": { "type": "integer", @@ -23214,12 +22339,20 @@ } }, { - "name": "maxHeight", + "name": "after", "in": "query", "schema": { "type": "integer", "format": "int32" } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -23230,7 +22363,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/OEmbedResponseModel" + "$ref": "#/components/schemas/SubsetMediaTreeItemResponseModel" } ] } @@ -23251,142 +22384,46 @@ ] } }, - "/umbraco/management/api/v1/package/{name}/run-migration": { - "post": { + "/umbraco/management/api/v1/item/member-group": { + "get": { "tags": [ - "Package" + "Member Group" ], - "operationId": "PostPackageByNameRunMigration", + "operationId": "GetItemMemberGroup", "parameters": [ { - "name": "name", - "in": "path", - "required": true, + "name": "id", + "in": "query", "schema": { - "type": "string" - } - } - ], - "responses": { - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "409": { - "description": "Conflict", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" } } } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/package/configuration": { - "get": { - "tags": [ - "Package" ], - "operationId": "GetPackageConfiguration", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PackageConfigurationResponseModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberGroupItemResponseModel" + } + ] + } } } } }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -23396,12 +22433,12 @@ ] } }, - "/umbraco/management/api/v1/package/created": { + "/umbraco/management/api/v1/member-group": { "get": { "tags": [ - "Package" + "Member Group" ], - "operationId": "GetPackageCreated", + "operationId": "GetMemberGroup", "parameters": [ { "name": "skip", @@ -23430,7 +22467,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedPackageDefinitionResponseModel" + "$ref": "#/components/schemas/PagedMemberGroupResponseModel" } ] } @@ -23452,16 +22489,16 @@ }, "post": { "tags": [ - "Package" + "Member Group" ], - "operationId": "PostPackageCreated", + "operationId": "PostMemberGroup", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreatePackageRequestModel" + "$ref": "#/components/schemas/CreateMemberGroupRequestModel" } ] } @@ -23470,7 +22507,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreatePackageRequestModel" + "$ref": "#/components/schemas/CreateMemberGroupRequestModel" } ] } @@ -23479,7 +22516,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreatePackageRequestModel" + "$ref": "#/components/schemas/CreateMemberGroupRequestModel" } ] } @@ -23487,9 +22524,24 @@ } }, "responses": { - "404": { - "description": "Not Found", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -23500,17 +22552,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "400": { @@ -23539,36 +22580,6 @@ } } }, - "201": { - "description": "Created", - "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -23595,12 +22606,12 @@ ] } }, - "/umbraco/management/api/v1/package/created/{id}": { + "/umbraco/management/api/v1/member-group/{id}": { "get": { "tags": [ - "Package" + "Member Group" ], - "operationId": "GetPackageCreatedById", + "operationId": "GetMemberGroupById", "parameters": [ { "name": "id", @@ -23613,20 +22624,6 @@ } ], "responses": { - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, "200": { "description": "OK", "content": { @@ -23634,13 +22631,16 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PackageDefinitionResponseModel" + "$ref": "#/components/schemas/MemberGroupResponseModel" } ] } } } }, + "404": { + "description": "Not Found" + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -23656,9 +22656,9 @@ }, "delete": { "tags": [ - "Package" + "Member Group" ], - "operationId": "DeletePackageCreatedById", + "operationId": "DeleteMemberGroupById", "parameters": [ { "name": "id", @@ -23671,6 +22671,32 @@ } ], "responses": { + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "404": { "description": "Not Found", "headers": { @@ -23739,9 +22765,9 @@ }, "put": { "tags": [ - "Package" + "Member Group" ], - "operationId": "PutPackageCreatedById", + "operationId": "PutMemberGroupById", "parameters": [ { "name": "id", @@ -23759,7 +22785,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdatePackageRequestModel" + "$ref": "#/components/schemas/UpdateMemberGroupRequestModel" } ] } @@ -23768,7 +22794,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdatePackageRequestModel" + "$ref": "#/components/schemas/UpdateMemberGroupRequestModel" } ] } @@ -23777,7 +22803,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdatePackageRequestModel" + "$ref": "#/components/schemas/UpdateMemberGroupRequestModel" } ] } @@ -23785,8 +22811,23 @@ } }, "responses": { - "404": { - "description": "Not Found", + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -23811,8 +22852,8 @@ } } }, - "200": { - "description": "OK", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -23824,6 +22865,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "401": { @@ -23852,38 +22904,33 @@ ] } }, - "/umbraco/management/api/v1/package/created/{id}/download": { + "/umbraco/management/api/v1/tree/member-group/root": { "get": { "tags": [ - "Package" + "Member Group" ], - "operationId": "GetPackageCreatedByIdDownload", + "operationId": "GetTreeMemberGroupRoot", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "skip", + "in": "query", "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } ], "responses": { - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, "200": { "description": "OK", "content": { @@ -23891,8 +22938,7 @@ "schema": { "oneOf": [ { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/PagedNamedEntityTreeItemResponseModel" } ] } @@ -23913,29 +22959,23 @@ ] } }, - "/umbraco/management/api/v1/package/migration-status": { + "/umbraco/management/api/v1/item/member-type": { "get": { "tags": [ - "Package" + "Member Type" ], - "operationId": "GetPackageMigrationStatus", + "operationId": "GetItemMemberType", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", + "name": "id", "in": "query", "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } } } ], @@ -23945,20 +22985,20 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedPackageMigrationStatusResponseModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberTypeItemResponseModel" + } + ] + } } } } }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -23968,22 +23008,36 @@ ] } }, - "/umbraco/management/api/v1/item/partial-view": { + "/umbraco/management/api/v1/item/member-type/search": { "get": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "GetItemPartialView", + "operationId": "GetItemMemberTypeSearch", "parameters": [ { - "name": "path", + "name": "query", "in": "query", "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string" - } + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } ], @@ -23993,14 +23047,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/PartialViewItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/PagedModelMemberTypeItemResponseModel" + } + ] } } } @@ -24016,19 +23067,19 @@ ] } }, - "/umbraco/management/api/v1/partial-view": { + "/umbraco/management/api/v1/member-type": { "post": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "PostPartialView", + "operationId": "PostMemberType", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreatePartialViewRequestModel" + "$ref": "#/components/schemas/CreateMemberTypeRequestModel" } ] } @@ -24037,7 +23088,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreatePartialViewRequestModel" + "$ref": "#/components/schemas/CreateMemberTypeRequestModel" } ] } @@ -24046,7 +23097,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreatePartialViewRequestModel" + "$ref": "#/components/schemas/CreateMemberTypeRequestModel" } ] } @@ -24162,19 +23213,20 @@ ] } }, - "/umbraco/management/api/v1/partial-view/{path}": { + "/umbraco/management/api/v1/member-type/{id}": { "get": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "GetPartialViewByPath", + "operationId": "GetMemberTypeById", "parameters": [ { - "name": "path", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -24186,7 +23238,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PartialViewResponseModel" + "$ref": "#/components/schemas/MemberTypeResponseModel" } ] } @@ -24222,16 +23274,17 @@ }, "delete": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "DeletePartialViewByPath", + "operationId": "DeleteMemberTypeById", "parameters": [ { - "name": "path", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -24251,32 +23304,6 @@ } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, "404": { "description": "Not Found", "headers": { @@ -24330,16 +23357,17 @@ }, "put": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "PutPartialViewByPath", + "operationId": "PutMemberTypeById", "parameters": [ { - "name": "path", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -24349,7 +23377,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdatePartialViewRequestModel" + "$ref": "#/components/schemas/UpdateMemberTypeRequestModel" } ] } @@ -24358,7 +23386,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdatePartialViewRequestModel" + "$ref": "#/components/schemas/UpdateMemberTypeRequestModel" } ] } @@ -24367,7 +23395,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdatePartialViewRequestModel" + "$ref": "#/components/schemas/UpdateMemberTypeRequestModel" } ] } @@ -24468,19 +23496,97 @@ ] } }, - "/umbraco/management/api/v1/partial-view/{path}/rename": { - "put": { + "/umbraco/management/api/v1/member-type/{id}/composition-references": { + "get": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "PutPartialViewByPathRename", + "operationId": "GetMemberTypeByIdCompositionReferences", "parameters": [ { - "name": "path", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberTypeCompositionResponseModel" + } + ] + } + } + } + } + }, + "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/member-type/{id}/copy": { + "post": { + "tags": [ + "Member Type" + ], + "operationId": "PostMemberTypeByIdCopy", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } } ], @@ -24490,7 +23596,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/RenamePartialViewRequestModel" + "$ref": "#/components/schemas/CopyMemberTypeRequestModel" } ] } @@ -24499,7 +23605,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/RenamePartialViewRequestModel" + "$ref": "#/components/schemas/CopyMemberTypeRequestModel" } ] } @@ -24508,7 +23614,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/RenamePartialViewRequestModel" + "$ref": "#/components/schemas/CopyMemberTypeRequestModel" } ] } @@ -24624,19 +23730,91 @@ ] } }, - "/umbraco/management/api/v1/partial-view/folder": { - "post": { + "/umbraco/management/api/v1/member-type/{id}/export": { + "get": { "tags": [ - "Partial View" + "Member Type" + ], + "operationId": "GetMemberTypeByIdExport", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "string", + "format": "binary" + } + ] + } + } + } + }, + "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/member-type/{id}/import": { + "put": { + "tags": [ + "Member Type" + ], + "operationId": "PutMemberTypeByIdImport", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "PostPartialViewFolder", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreatePartialViewFolderRequestModel" + "$ref": "#/components/schemas/ImportMemberTypeRequestModel" } ] } @@ -24645,7 +23823,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreatePartialViewFolderRequestModel" + "$ref": "#/components/schemas/ImportMemberTypeRequestModel" } ] } @@ -24654,7 +23832,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreatePartialViewFolderRequestModel" + "$ref": "#/components/schemas/ImportMemberTypeRequestModel" } ] } @@ -24662,24 +23840,9 @@ } }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -24692,8 +23855,8 @@ } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -24718,8 +23881,8 @@ } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -24770,31 +23933,90 @@ ] } }, - "/umbraco/management/api/v1/partial-view/folder/{path}": { - "get": { + "/umbraco/management/api/v1/member-type/{id}/move": { + "put": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "GetPartialViewFolderByPath", + "operationId": "PutMemberTypeByIdMove", "parameters": [ { - "name": "path", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MoveMemberTypeRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MoveMemberTypeRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MoveMemberTypeRequestModel" + } + ] + } + } + } + }, "responses": { "200": { "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PartialViewFolderResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -24803,6 +24025,18 @@ }, "404": { "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { @@ -24819,7 +24053,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -24827,26 +24073,192 @@ "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/member-type/available-compositions": { + "post": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "DeletePartialViewFolderByPath", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string" + "operationId": "PostMemberTypeAvailableCompositions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberTypeCompositionRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberTypeCompositionRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberTypeCompositionRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/AvailableMemberTypeCompositionResponseModel" + } + ] + } + } + } } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] } + ] + } + }, + "/umbraco/management/api/v1/member-type/configuration": { + "get": { + "tags": [ + "Member Type" ], + "operationId": "GetMemberTypeConfiguration", "responses": { "200": { "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemberTypeConfigurationResponseModel" + } + ] + } + } + } + }, + "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/member-type/folder": { + "post": { + "tags": [ + "Member Type" + ], + "operationId": "PostMemberTypeFolder", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateFolderRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateFolderRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateFolderRequestModel" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -24937,29 +24349,20 @@ ] } }, - "/umbraco/management/api/v1/partial-view/snippet": { + "/umbraco/management/api/v1/member-type/folder/{id}": { "get": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "GetPartialViewSnippet", + "operationId": "GetMemberTypeFolderById", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "type": "string", + "format": "uuid" } } ], @@ -24971,7 +24374,21 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedPartialViewSnippetItemResponseModel" + "$ref": "#/components/schemas/FolderResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -24990,33 +24407,59 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/partial-view/snippet/{id}": { - "get": { + }, + "delete": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "GetPartialViewSnippetById", + "operationId": "DeleteMemberTypeFolderById", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PartialViewSnippetResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -25025,6 +24468,18 @@ }, "404": { "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { @@ -25041,7 +24496,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -25049,20 +24516,307 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/tree/partial-view/ancestors": { + }, + "put": { + "tags": [ + "Member Type" + ], + "operationId": "PutMemberTypeFolderById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateFolderResponseModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateFolderResponseModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateFolderResponseModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/member-type/import": { + "post": { + "tags": [ + "Member Type" + ], + "operationId": "PostMemberTypeImport", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ImportMemberTypeRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ImportMemberTypeRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ImportMemberTypeRequestModel" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/tree/member-type/ancestors": { "get": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "GetTreePartialViewAncestors", + "operationId": "GetTreeMemberTypeAncestors", "parameters": [ { - "name": "descendantPath", + "name": "descendantId", "in": "query", "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -25076,7 +24830,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/FileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/MemberTypeTreeItemResponseModel" } ] } @@ -25098,18 +24852,19 @@ ] } }, - "/umbraco/management/api/v1/tree/partial-view/children": { + "/umbraco/management/api/v1/tree/member-type/children": { "get": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "GetTreePartialViewChildren", + "operationId": "GetTreeMemberTypeChildren", "parameters": [ { - "name": "parentPath", + "name": "parentId", "in": "query", "schema": { - "type": "string" + "type": "string", + "format": "uuid" } }, { @@ -25129,6 +24884,14 @@ "format": "int32", "default": 100 } + }, + { + "name": "foldersOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -25139,7 +24902,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/PagedMemberTypeTreeItemResponseModel" } ] } @@ -25160,12 +24923,12 @@ ] } }, - "/umbraco/management/api/v1/tree/partial-view/root": { + "/umbraco/management/api/v1/tree/member-type/root": { "get": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "GetTreePartialViewRoot", + "operationId": "GetTreeMemberTypeRoot", "parameters": [ { "name": "skip", @@ -25184,6 +24947,14 @@ "format": "int32", "default": 100 } + }, + { + "name": "foldersOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -25194,7 +24965,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/PagedMemberTypeTreeItemResponseModel" } ] } @@ -25215,18 +24986,19 @@ ] } }, - "/umbraco/management/api/v1/tree/partial-view/siblings": { + "/umbraco/management/api/v1/tree/member-type/siblings": { "get": { "tags": [ - "Partial View" + "Member Type" ], - "operationId": "GetTreePartialViewSiblings", + "operationId": "GetTreeMemberTypeSiblings", "parameters": [ { - "name": "path", + "name": "target", "in": "query", "schema": { - "type": "string" + "type": "string", + "format": "uuid" } }, { @@ -25244,6 +25016,14 @@ "type": "integer", "format": "int32" } + }, + { + "name": "foldersOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -25254,7 +25034,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SubsetFileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/SubsetMemberTypeTreeItemResponseModel" } ] } @@ -25275,78 +25055,106 @@ ] } }, - "/umbraco/management/api/v1/preview": { - "delete": { + "/umbraco/management/api/v1/filter/member": { + "get": { "tags": [ - "Preview" + "Member" ], - "operationId": "DeletePreview", - "responses": { - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } + "operationId": "GetFilterMember", + "parameters": [ + { + "name": "memberTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "memberGroupName", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "isApproved", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isLockedOut", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "orderBy", + "in": "query", + "schema": { + "type": "string", + "default": "username" + } + }, + { + "name": "orderDirection", + "in": "query", + "schema": { + "$ref": "#/components/schemas/DirectionModel" + } + }, + { + "name": "filter", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } - } - }, - "post": { - "tags": [ - "Preview" ], - "operationId": "PostPreview", "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "content": { + "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/PagedMemberResponseModel" + } + ] } } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "deprecated": true, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/profiling/status": { - "get": { - "tags": [ - "Profiling" - ], - "operationId": "GetProfilingStatus", - "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProfilingStatusResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -25365,76 +25173,48 @@ "Backoffice-User": [ ] } ] - }, - "put": { + } + }, + "/umbraco/management/api/v1/item/member": { + "get": { "tags": [ - "Profiling" + "Member" ], - "operationId": "PutProfilingStatus", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProfilingStatusRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProfilingStatusRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProfilingStatusRequestModel" - } - ] + "operationId": "GetItemMember", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" } } } - }, + ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "content": { + "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/MemberItemResponseModel" + } + ] + } } } } }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } } }, "security": [ @@ -25444,59 +25224,67 @@ ] } }, - "/umbraco/management/api/v1/property-type/is-used": { + "/umbraco/management/api/v1/item/member/search": { "get": { "tags": [ - "Property Type" + "Member" ], - "operationId": "GetPropertyTypeIsUsed", + "operationId": "GetItemMemberSearch", "parameters": [ { - "name": "contentTypeId", + "name": "query", "in": "query", "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, { - "name": "propertyAlias", + "name": "skip", "in": "query", "schema": { - "type": "string" + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "allowedMemberTypes", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } } } ], "responses": { - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedModelMemberItemResponseModel" } ] } } } }, - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -25506,16 +25294,62 @@ ] } }, - "/umbraco/management/api/v1/published-cache/rebuild": { + "/umbraco/management/api/v1/member": { "post": { "tags": [ - "Published Cache" + "Member" ], - "operationId": "PostPublishedCacheRebuild", + "operationId": "PostMember", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateMemberRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateMemberRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateMemberRequestModel" + } + ] + } + } + } + }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -25528,58 +25362,34 @@ } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/published-cache/rebuild/status": { - "get": { - "tags": [ - "Published Cache" - ], - "operationId": "GetPublishedCacheRebuildStatus", - "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/RebuildStatusModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/published-cache/reload": { - "post": { - "tags": [ - "Published Cache" - ], - "operationId": "PostPublishedCacheReload", - "responses": { - "200": { - "description": "OK", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -25591,10 +25401,36 @@ "nullable": true } } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -25604,62 +25440,46 @@ ] } }, - "/umbraco/management/api/v1/redirect-management": { + "/umbraco/management/api/v1/member/{id}": { "get": { "tags": [ - "Redirect Management" + "Member" ], - "operationId": "GetRedirectManagement", + "operationId": "GetMemberById", "parameters": [ { - "name": "filter", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "type": "string", + "format": "uuid" } } ], "responses": { - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/MemberResponseModel" } ] } } } }, - "200": { - "description": "OK", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedRedirectUrlResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -25678,14 +25498,12 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/redirect-management/{id}": { - "get": { + }, + "delete": { "tags": [ - "Redirect Management" + "Member" ], - "operationId": "GetRedirectManagementById", + "operationId": "DeleteMemberById", "parameters": [ { "name": "id", @@ -25695,35 +25513,70 @@ "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": 100 - } } ], "responses": { "200": { "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedRedirectUrlResponseModel" + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -25734,7 +25587,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -25743,11 +25608,11 @@ } ] }, - "delete": { + "put": { "tags": [ - "Redirect Management" + "Member" ], - "operationId": "DeleteRedirectManagementById", + "operationId": "PutMemberById", "parameters": [ { "name": "id", @@ -25759,6 +25624,37 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateMemberRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateMemberRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateMemberRequestModel" + } + ] + } + } + } + }, "responses": { "200": { "description": "OK", @@ -25775,11 +25671,8 @@ } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -25791,67 +25684,21 @@ "nullable": true } } - } - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/redirect-management/status": { - "get": { - "tags": [ - "Redirect Management" - ], - "operationId": "GetRedirectManagementStatus", - "responses": { - "200": { - "description": "OK", + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/RedirectUrlStatusResponseModel" + "$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": [ ] - } - ] - }, - "post": { - "tags": [ - "Redirect Management" - ], - "operationId": "PostRedirectManagementStatus", - "parameters": [ - { - "name": "status", - "in": "query", - "schema": { - "$ref": "#/components/schemas/RedirectStatusModel" - } - } - ], - "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -25863,6 +25710,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "401": { @@ -25891,62 +25749,22 @@ ] } }, - "/umbraco/management/api/v1/item/relation-type": { + "/umbraco/management/api/v1/member/{id}/referenced-by": { "get": { "tags": [ - "Relation Type" + "Member" ], - "operationId": "GetItemRelationType", + "operationId": "GetMemberByIdReferencedBy", "parameters": [ { "name": "id", - "in": "query", + "in": "path", + "required": true, "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/RelationTypeItemResponseModel" - } - ] - } - } - } + "type": "string", + "format": "uuid" } }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/relation-type": { - "get": { - "tags": [ - "Relation Type" - ], - "operationId": "GetRelationType", - "parameters": [ { "name": "skip", "in": "query", @@ -25962,53 +25780,7 @@ "schema": { "type": "integer", "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedRelationTypeResponseModel" - } - ] - } - } - } - }, - "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/relation-type/{id}": { - "get": { - "tags": [ - "Relation Type" - ], - "operationId": "GetRelationTypeById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "default": 20 } } ], @@ -26020,7 +25792,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/RelationTypeResponseModel" + "$ref": "#/components/schemas/PagedIReferenceResponseModel" } ] } @@ -26055,12 +25827,12 @@ ] } }, - "/umbraco/management/api/v1/relation/type/{id}": { + "/umbraco/management/api/v1/member/{id}/referenced-descendants": { "get": { "tags": [ - "Relation" + "Member" ], - "operationId": "GetRelationByRelationTypeId", + "operationId": "GetMemberByIdReferencedDescendants", "parameters": [ { "name": "id", @@ -26086,7 +25858,7 @@ "schema": { "type": "integer", "format": "int32", - "default": 100 + "default": 20 } } ], @@ -26098,7 +25870,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedRelationResponseModel" + "$ref": "#/components/schemas/PagedReferenceByIdModel" } ] } @@ -26112,7 +25884,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedProblemDetailsModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -26133,67 +25905,30 @@ ] } }, - "/umbraco/management/api/v1/item/script": { - "get": { + "/umbraco/management/api/v1/member/{id}/validate": { + "put": { "tags": [ - "Script" + "Member" ], - "operationId": "GetItemScript", + "operationId": "PutMemberByIdValidate", "parameters": [ { - "name": "path", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ScriptItemResponseModel" - } - ] - } - } - } + "type": "string", + "format": "uuid" } - }, - "401": { - "description": "The resource is protected and requires an authentication token" } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/script": { - "post": { - "tags": [ - "Script" ], - "operationId": "PostScript", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateScriptRequestModel" + "$ref": "#/components/schemas/UpdateMemberRequestModel" } ] } @@ -26202,7 +25937,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateScriptRequestModel" + "$ref": "#/components/schemas/UpdateMemberRequestModel" } ] } @@ -26211,7 +25946,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateScriptRequestModel" + "$ref": "#/components/schemas/UpdateMemberRequestModel" } ] } @@ -26219,24 +25954,9 @@ } }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -26327,19 +26047,41 @@ ] } }, - "/umbraco/management/api/v1/script/{path}": { + "/umbraco/management/api/v1/member/are-referenced": { "get": { "tags": [ - "Script" + "Member" ], - "operationId": "GetScriptByPath", + "operationId": "GetMemberAreReferenced", "parameters": [ { - "name": "path", - "in": "path", - "required": true, + "name": "id", + "in": "query", "schema": { - "type": "string" + "uniqueItems": true, + "type": "array", + "items": { + "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 } } ], @@ -26351,21 +26093,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ScriptResponseModel" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedReferenceByIdModel" } ] } @@ -26384,84 +26112,23 @@ "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/member/configuration": { + "get": { "tags": [ - "Script" - ], - "operationId": "DeleteScriptByPath", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } + "Member" ], + "operationId": "GetMemberConfiguration", "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/MemberConfigurationResponseModel" } ] } @@ -26472,19 +26139,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -26492,29 +26147,21 @@ "Backoffice-User": [ ] } ] - }, - "put": { + } + }, + "/umbraco/management/api/v1/member/validate": { + "post": { "tags": [ - "Script" - ], - "operationId": "PutScriptByPath", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } + "Member" ], + "operationId": "PostMemberValidate", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateScriptRequestModel" + "$ref": "#/components/schemas/CreateMemberRequestModel" } ] } @@ -26523,7 +26170,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateScriptRequestModel" + "$ref": "#/components/schemas/CreateMemberRequestModel" } ] } @@ -26532,7 +26179,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateScriptRequestModel" + "$ref": "#/components/schemas/CreateMemberRequestModel" } ] } @@ -26633,72 +26280,16 @@ ] } }, - "/umbraco/management/api/v1/script/{path}/rename": { - "put": { + "/umbraco/management/api/v1/models-builder/build": { + "post": { "tags": [ - "Script" - ], - "operationId": "PutScriptByPathRename", - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } + "Models Builder" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/RenameScriptRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/RenameScriptRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/RenameScriptRequestModel" - } - ] - } - } - } - }, + "operationId": "PostModelsBuilderBuild", "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -26711,8 +26302,8 @@ } } }, - "400": { - "description": "Bad Request", + "428": { + "description": "Precondition Required", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -26737,8 +26328,11 @@ } } }, - "404": { - "description": "Not Found", + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -26750,13 +26344,31 @@ "nullable": true } } - }, + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/models-builder/dashboard": { + "get": { + "tags": [ + "Models Builder" + ], + "operationId": "GetModelsBuilderDashboard", + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/ModelsBuilderResponseModel" } ] } @@ -26767,19 +26379,42 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/models-builder/status": { + "get": { + "tags": [ + "Models Builder" + ], + "operationId": "GetModelsBuilderStatus", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/OutOfDateStatusResponseModel" + } + ] } } } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -26789,120 +26424,21 @@ ] } }, - "/umbraco/management/api/v1/script/folder": { - "post": { + "/umbraco/management/api/v1/news-dashboard": { + "get": { "tags": [ - "Script" + "News Dashboard" ], - "operationId": "PostScriptFolder", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateScriptFolderRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateScriptFolderRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateScriptFolderRequestModel" - } - ] - } - } - } - }, + "operationId": "GetNewsDashboard", "responses": { - "201": { - "description": "Created", - "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/NewsDashboardResponseModel" } ] } @@ -26911,21 +26447,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } } }, "security": [ @@ -26935,19 +26456,29 @@ ] } }, - "/umbraco/management/api/v1/script/folder/{path}": { + "/umbraco/management/api/v1/object-types": { "get": { "tags": [ - "Script" + "Object Types" ], - "operationId": "GetScriptFolderByPath", + "operationId": "GetObjectTypes", "parameters": [ { - "name": "path", - "in": "path", - "required": true, + "name": "skip", + "in": "query", "schema": { - "type": "string" + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } ], @@ -26959,21 +26490,65 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ScriptFolderResponseModel" + "$ref": "#/components/schemas/PagedObjectTypeResponseModel" } ] } } } }, - "404": { - "description": "Not Found", + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/oembed/query": { + "get": { + "tags": [ + "oEmbed" + ], + "operationId": "GetOembedQuery", + "parameters": [ + { + "name": "url", + "in": "query", + "schema": { + "type": "string", + "format": "uri" + } + }, + { + "name": "maxWidth", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "maxHeight", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/OEmbedResponseModel" } ] } @@ -26992,15 +26567,17 @@ "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/package/{name}/run-migration": { + "post": { "tags": [ - "Script" + "Package" ], - "operationId": "DeleteScriptFolderByPath", + "operationId": "PostPackageByNameRunMigration", "parameters": [ { - "name": "path", + "name": "name", "in": "path", "required": true, "schema": { @@ -27009,8 +26586,8 @@ } ], "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -27022,10 +26599,21 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, - "400": { - "description": "Bad Request", + "409": { + "description": "Conflict", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -27050,8 +26638,8 @@ } } }, - "404": { - "description": "Not Found", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -27063,17 +26651,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "401": { @@ -27102,35 +26679,23 @@ ] } }, - "/umbraco/management/api/v1/tree/script/ancestors": { + "/umbraco/management/api/v1/package/configuration": { "get": { "tags": [ - "Script" - ], - "operationId": "GetTreeScriptAncestors", - "parameters": [ - { - "name": "descendantPath", - "in": "query", - "schema": { - "type": "string" - } - } + "Package" ], + "operationId": "GetPackageConfiguration", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/FileSystemTreeItemPresentationModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/PackageConfigurationResponseModel" + } + ] } } } @@ -27149,20 +26714,13 @@ ] } }, - "/umbraco/management/api/v1/tree/script/children": { + "/umbraco/management/api/v1/package/created": { "get": { "tags": [ - "Script" + "Package" ], - "operationId": "GetTreeScriptChildren", + "operationId": "GetPackageCreated", "parameters": [ - { - "name": "parentPath", - "in": "query", - "schema": { - "type": "string" - } - }, { "name": "skip", "in": "query", @@ -27190,7 +26748,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/PagedPackageDefinitionResponseModel" } ] } @@ -27209,54 +26767,143 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/tree/script/root": { - "get": { + }, + "post": { "tags": [ - "Script" + "Package" ], - "operationId": "GetTreeScriptRoot", - "parameters": [ - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "operationId": "PostPackageCreated", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreatePackageRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreatePackageRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreatePackageRequestModel" + } + ] + } } } - ], + }, "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } } } }, + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -27266,38 +26913,38 @@ ] } }, - "/umbraco/management/api/v1/tree/script/siblings": { + "/umbraco/management/api/v1/package/created/{id}": { "get": { "tags": [ - "Script" + "Package" ], - "operationId": "GetTreeScriptSiblings", + "operationId": "GetPackageCreatedById", "parameters": [ { - "name": "path", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "before", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "after", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32" + "type": "string", + "format": "uuid" } } ], "responses": { + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "200": { "description": "OK", "content": { @@ -27305,7 +26952,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SubsetFileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/PackageDefinitionResponseModel" } ] } @@ -27324,51 +26971,82 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/searcher": { - "get": { + }, + "delete": { "tags": [ - "Searcher" + "Package" ], - "operationId": "GetSearcher", + "operationId": "DeletePackageCreatedById", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "type": "string", + "format": "uuid" } } ], "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedSearcherResponseModel" + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } } } }, + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -27376,72 +27054,163 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/searcher/{searcherName}/query": { - "get": { + }, + "put": { "tags": [ - "Searcher" + "Package" ], - "operationId": "GetSearcherBySearcherNameQuery", + "operationId": "PutPackageCreatedById", "parameters": [ { - "name": "searcherName", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } - }, - { - "name": "term", - "in": "query", - "schema": { - "type": "string" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdatePackageRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdatePackageRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdatePackageRequestModel" + } + ] + } + } + } + }, + "responses": { + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } } }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ { - "name": "take", - "in": "query", + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/package/created/{id}/download": { + "get": { + "tags": [ + "Package" + ], + "operationId": "GetPackageCreatedByIdDownload", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "type": "string", + "format": "uuid" } } ], "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedSearchResultResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } } } }, - "404": { - "description": "Not Found", + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "type": "string", + "format": "binary" } ] } @@ -27450,6 +27219,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -27459,12 +27231,32 @@ ] } }, - "/umbraco/management/api/v1/security/configuration": { + "/umbraco/management/api/v1/package/migration-status": { "get": { "tags": [ - "Security" + "Package" + ], + "operationId": "GetPackageMigrationStatus", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } ], - "operationId": "GetSecurityConfiguration", "responses": { "200": { "description": "OK", @@ -27473,7 +27265,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SecurityConfigurationResponseModel" + "$ref": "#/components/schemas/PagedPackageMigrationStatusResponseModel" } ] } @@ -27494,19 +27286,67 @@ ] } }, - "/umbraco/management/api/v1/security/forgot-password": { + "/umbraco/management/api/v1/item/partial-view": { + "get": { + "tags": [ + "Partial View" + ], + "operationId": "GetItemPartialView", + "parameters": [ + { + "name": "path", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/PartialViewItemResponseModel" + } + ] + } + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/partial-view": { "post": { "tags": [ - "Security" + "Partial View" ], - "operationId": "PostSecurityForgotPassword", + "operationId": "PostPartialView", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ResetPasswordRequestModel" + "$ref": "#/components/schemas/CreatePartialViewRequestModel" } ] } @@ -27515,7 +27355,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ResetPasswordRequestModel" + "$ref": "#/components/schemas/CreatePartialViewRequestModel" } ] } @@ -27524,7 +27364,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ResetPasswordRequestModel" + "$ref": "#/components/schemas/CreatePartialViewRequestModel" } ] } @@ -27532,9 +27372,24 @@ } }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -27573,6 +27428,32 @@ } } }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -27599,46 +27480,82 @@ ] } }, - "/umbraco/management/api/v1/security/forgot-password/reset": { - "post": { + "/umbraco/management/api/v1/partial-view/{path}": { + "get": { "tags": [ - "Security" + "Partial View" ], - "operationId": "PostSecurityForgotPasswordReset", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" - } - ] + "operationId": "GetPartialViewByPath", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PartialViewResponseModel" + } + ] + } } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" - } - ] + } + }, + "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": [ ] + } + ] + }, + "delete": { + "tags": [ + "Partial View" + ], + "operationId": "DeletePartialViewByPath", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { - "204": { - "description": "No Content", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -27671,7 +27588,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -27697,7 +27614,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -27728,21 +27645,29 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/security/forgot-password/verify": { - "post": { + }, + "put": { "tags": [ - "Security" + "Partial View" + ], + "operationId": "PutPartialViewByPath", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "operationId": "PostSecurityForgotPasswordVerify", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/VerifyResetPasswordTokenRequestModel" + "$ref": "#/components/schemas/UpdatePartialViewRequestModel" } ] } @@ -27751,7 +27676,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/VerifyResetPasswordTokenRequestModel" + "$ref": "#/components/schemas/UpdatePartialViewRequestModel" } ] } @@ -27760,7 +27685,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/VerifyResetPasswordTokenRequestModel" + "$ref": "#/components/schemas/UpdatePartialViewRequestModel" } ] } @@ -27781,17 +27706,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/VerifyResetPasswordResponseModel" - } - ] - } - } } }, "400": { @@ -27813,7 +27727,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -27839,378 +27753,163 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + "$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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } - } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] } }, - "/umbraco/management/api/v1/segment": { - "get": { + "/umbraco/management/api/v1/partial-view/{path}/rename": { + "put": { "tags": [ - "Segment" + "Partial View" ], - "operationId": "GetSegment", + "operationId": "PutPartialViewByPathRename", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", + "name": "path", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "type": "string" } } ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedSegmentResponseModel" - } - ] - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/RenamePartialViewRequestModel" + } + ] } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/RenamePartialViewRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/RenamePartialViewRequestModel" + } + ] } } - }, - "401": { - "description": "The resource is protected and requires an authentication token" } }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/server/configuration": { - "get": { - "tags": [ - "Server" - ], - "operationId": "GetServerConfiguration", "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ServerConfigurationResponseModel" - } - ] + "type": "string", + "description": "Identifier of the newly created resource" } - } - } - } - } - } - }, - "/umbraco/management/api/v1/server/information": { - "get": { - "tags": [ - "Server" - ], - "operationId": "GetServerInformation", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "Location": { + "description": "Location of the newly created resource", "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ServerInformationResponseModel" - } - ] + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/server/status": { - "get": { - "tags": [ - "Server" - ], - "operationId": "GetServerStatus", - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } } }, - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ServerStatusResponseModel" - } - ] - } - } - } - } - } - } - }, - "/umbraco/management/api/v1/server/troubleshooting": { - "get": { - "tags": [ - "Server" - ], - "operationId": "GetServerTroubleshooting", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ServerTroubleshootingResponseModel" - } - ] + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/server/upgrade-check": { - "get": { - "tags": [ - "Server" - ], - "operationId": "GetServerUpgradeCheck", - "responses": { - "200": { - "description": "OK", + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpgradeCheckResponseModel" + "$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" - } - }, - "deprecated": true, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/item/static-file": { - "get": { - "tags": [ - "Static File" - ], - "operationId": "GetItemStaticFile", - "parameters": [ - { - "name": "path", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/StaticFileItemResponseModel" - } - ] - } - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/tree/static-file/ancestors": { - "get": { - "tags": [ - "Static File" - ], - "operationId": "GetTreeStaticFileAncestors", - "parameters": [ - { - "name": "descendantPath", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/FileSystemTreeItemPresentationModel" - } - ] - } + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/tree/static-file/children": { - "get": { - "tags": [ - "Static File" - ], - "operationId": "GetTreeStaticFileChildren", - "parameters": [ - { - "name": "parentPath", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -28219,106 +27918,21 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/tree/static-file/root": { - "get": { - "tags": [ - "Static File" - ], - "operationId": "GetTreeStaticFileRoot", - "parameters": [ - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" - } - ] - } - } - } }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/item/stylesheet": { - "get": { - "tags": [ - "Stylesheet" - ], - "operationId": "GetItemStylesheet", - "parameters": [ - { - "name": "path", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/StylesheetItemResponseModel" - } - ] - } + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true } } } - }, - "401": { - "description": "The resource is protected and requires an authentication token" } }, "security": [ @@ -28328,19 +27942,19 @@ ] } }, - "/umbraco/management/api/v1/stylesheet": { + "/umbraco/management/api/v1/partial-view/folder": { "post": { "tags": [ - "Stylesheet" + "Partial View" ], - "operationId": "PostStylesheet", + "operationId": "PostPartialViewFolder", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateStylesheetRequestModel" + "$ref": "#/components/schemas/CreatePartialViewFolderRequestModel" } ] } @@ -28349,7 +27963,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateStylesheetRequestModel" + "$ref": "#/components/schemas/CreatePartialViewFolderRequestModel" } ] } @@ -28358,7 +27972,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateStylesheetRequestModel" + "$ref": "#/components/schemas/CreatePartialViewFolderRequestModel" } ] } @@ -28474,12 +28088,12 @@ ] } }, - "/umbraco/management/api/v1/stylesheet/{path}": { + "/umbraco/management/api/v1/partial-view/folder/{path}": { "get": { "tags": [ - "Stylesheet" + "Partial View" ], - "operationId": "GetStylesheetByPath", + "operationId": "GetPartialViewFolderByPath", "parameters": [ { "name": "path", @@ -28498,7 +28112,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/StylesheetResponseModel" + "$ref": "#/components/schemas/PartialViewFolderResponseModel" } ] } @@ -28534,9 +28148,9 @@ }, "delete": { "tags": [ - "Stylesheet" + "Partial View" ], - "operationId": "DeleteStylesheetByPath", + "operationId": "DeletePartialViewFolderByPath", "parameters": [ { "name": "path", @@ -28639,83 +28253,96 @@ "Backoffice-User": [ ] } ] - }, - "put": { + } + }, + "/umbraco/management/api/v1/partial-view/snippet": { + "get": { "tags": [ - "Stylesheet" + "Partial View" ], - "operationId": "PutStylesheetByPath", + "operationId": "GetPartialViewSnippet", "parameters": [ { - "name": "path", - "in": "path", - "required": true, + "name": "skip", + "in": "query", "schema": { - "type": "string" + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpdateStylesheetRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpdateStylesheetRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpdateStylesheetRequestModel" - } - ] + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedPartialViewSnippetItemResponseModel" + } + ] + } } } + }, + "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/partial-view/snippet/{id}": { + "get": { + "tags": [ + "Partial View" + ], + "operationId": "GetPartialViewSnippetById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "content": { + "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/PartialViewSnippetResponseModel" + } + ] } } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { @@ -28728,26 +28355,109 @@ } } }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "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/tree/partial-view/ancestors": { + "get": { + "tags": [ + "Partial View" + ], + "operationId": "GetTreePartialViewAncestors", + "parameters": [ + { + "name": "descendantPath", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/FileSystemTreeItemPresentationModel" + } + ] + } } } - }, + } + }, + "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/tree/partial-view/children": { + "get": { + "tags": [ + "Partial View" + ], + "operationId": "GetTreePartialViewChildren", + "parameters": [ + { + "name": "parentPath", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" } ] } @@ -28758,19 +28468,62 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/tree/partial-view/root": { + "get": { + "tags": [ + "Partial View" + ], + "operationId": "GetTreePartialViewRoot", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" + } + ] } } } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -28780,72 +28533,76 @@ ] } }, - "/umbraco/management/api/v1/stylesheet/{path}/rename": { - "put": { + "/umbraco/management/api/v1/tree/partial-view/siblings": { + "get": { "tags": [ - "Stylesheet" + "Partial View" ], - "operationId": "PutStylesheetByPathRename", + "operationId": "GetTreePartialViewSiblings", "parameters": [ { "name": "path", - "in": "path", - "required": true, + "in": "query", "schema": { "type": "string" } + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/RenameStylesheetRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/RenameStylesheetRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/RenameStylesheetRequestModel" - } - ] + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetFileSystemTreeItemPresentationModel" + } + ] + } } } + }, + "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/preview": { + "delete": { + "tags": [ + "Preview" + ], + "operationId": "DeletePreview", "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -28857,9 +28614,17 @@ } } } - }, - "400": { - "description": "Bad Request", + } + } + }, + "post": { + "tags": [ + "Preview" + ], + "operationId": "PostPreview", + "responses": { + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -28871,39 +28636,35 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "deprecated": true, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/profiling/status": { + "get": { + "tags": [ + "Profiling" + ], + "operationId": "GetProfilingStatus", + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/ProfilingStatusResponseModel" } ] } @@ -28914,19 +28675,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -28934,21 +28683,19 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/stylesheet/folder": { - "post": { + }, + "put": { "tags": [ - "Stylesheet" + "Profiling" ], - "operationId": "PostStylesheetFolder", + "operationId": "PutProfilingStatus", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateStylesheetFolderRequestModel" + "$ref": "#/components/schemas/ProfilingStatusRequestModel" } ] } @@ -28957,7 +28704,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateStylesheetFolderRequestModel" + "$ref": "#/components/schemas/ProfilingStatusRequestModel" } ] } @@ -28966,7 +28713,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateStylesheetFolderRequestModel" + "$ref": "#/components/schemas/ProfilingStatusRequestModel" } ] } @@ -28974,24 +28721,9 @@ } }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -29004,8 +28736,11 @@ } } }, - "400": { - "description": "Bad Request", + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -29017,7 +28752,42 @@ "nullable": true } } - }, + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/property-type/is-used": { + "get": { + "tags": [ + "Property Type" + ], + "operationId": "GetPropertyTypeIsUsed", + "parameters": [ + { + "name": "contentTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "propertyAlias", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", "content": { "application/json": { "schema": { @@ -29030,8 +28800,39 @@ } } }, - "404": { - "description": "Not Found", + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "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/published-cache/rebuild": { + "post": { + "tags": [ + "Published Cache" + ], + "operationId": "PostPublishedCacheRebuild", + "responses": { + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -29043,13 +28844,34 @@ "nullable": true } } - }, + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/published-cache/rebuild/status": { + "get": { + "tags": [ + "Published Cache" + ], + "operationId": "GetPublishedCacheRebuildStatus", + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/RebuildStatusModel" } ] } @@ -29058,9 +28880,24 @@ }, "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/published-cache/reload": { + "post": { + "tags": [ + "Published Cache" + ], + "operationId": "PostPublishedCacheReload", + "responses": { + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -29073,6 +28910,9 @@ } } } + }, + "401": { + "description": "The resource is protected and requires an authentication token" } }, "security": [ @@ -29082,23 +28922,54 @@ ] } }, - "/umbraco/management/api/v1/stylesheet/folder/{path}": { + "/umbraco/management/api/v1/redirect-management": { "get": { "tags": [ - "Stylesheet" + "Redirect Management" ], - "operationId": "GetStylesheetFolderByPath", + "operationId": "GetRedirectManagement", "parameters": [ { - "name": "path", - "in": "path", - "required": true, + "name": "filter", + "in": "query", "schema": { "type": "string" } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } } ], "responses": { + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "200": { "description": "OK", "content": { @@ -29106,21 +28977,71 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/StylesheetFolderResponseModel" + "$ref": "#/components/schemas/PagedRedirectUrlResponseModel" } ] } } } }, - "404": { - "description": "Not Found", + "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/redirect-management/{id}": { + "get": { + "tags": [ + "Redirect Management" + ], + "operationId": "GetRedirectManagementById", + "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": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedRedirectUrlResponseModel" } ] } @@ -29142,16 +29063,17 @@ }, "delete": { "tags": [ - "Stylesheet" + "Redirect Management" ], - "operationId": "DeleteStylesheetFolderByPath", + "operationId": "DeleteRedirectManagementById", "parameters": [ { - "name": "path", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -29171,8 +29093,11 @@ } } }, - "400": { - "description": "Bad Request", + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -29184,21 +29109,67 @@ "nullable": true } } - }, + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/redirect-management/status": { + "get": { + "tags": [ + "Redirect Management" + ], + "operationId": "GetRedirectManagementStatus", + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/RedirectUrlStatusResponseModel" } ] } } } }, - "404": { - "description": "Not Found", + "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": [ ] + } + ] + }, + "post": { + "tags": [ + "Redirect Management" + ], + "operationId": "PostRedirectManagementStatus", + "parameters": [ + { + "name": "status", + "in": "query", + "schema": { + "$ref": "#/components/schemas/RedirectStatusModel" + } + } + ], + "responses": { + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -29210,17 +29181,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "401": { @@ -29249,18 +29209,23 @@ ] } }, - "/umbraco/management/api/v1/tree/stylesheet/ancestors": { + "/umbraco/management/api/v1/item/relation-type": { "get": { "tags": [ - "Stylesheet" + "Relation Type" ], - "operationId": "GetTreeStylesheetAncestors", + "operationId": "GetItemRelationType", "parameters": [ { - "name": "descendantPath", + "name": "id", "in": "query", "schema": { - "type": "string" + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } } } ], @@ -29274,7 +29239,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/FileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/RelationTypeItemResponseModel" } ] } @@ -29284,9 +29249,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -29296,20 +29258,13 @@ ] } }, - "/umbraco/management/api/v1/tree/stylesheet/children": { + "/umbraco/management/api/v1/relation-type": { "get": { "tags": [ - "Stylesheet" + "Relation Type" ], - "operationId": "GetTreeStylesheetChildren", + "operationId": "GetRelationType", "parameters": [ - { - "name": "parentPath", - "in": "query", - "schema": { - "type": "string" - } - }, { "name": "skip", "in": "query", @@ -29337,7 +29292,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/PagedRelationTypeResponseModel" } ] } @@ -29358,29 +29313,20 @@ ] } }, - "/umbraco/management/api/v1/tree/stylesheet/root": { + "/umbraco/management/api/v1/relation-type/{id}": { "get": { "tags": [ - "Stylesheet" + "Relation Type" ], - "operationId": "GetTreeStylesheetRoot", + "operationId": "GetRelationTypeById", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "type": "string", + "format": "uuid" } } ], @@ -29392,67 +29338,21 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/RelationTypeResponseModel" } ] } } } }, - "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/tree/stylesheet/siblings": { - "get": { - "tags": [ - "Stylesheet" - ], - "operationId": "GetTreeStylesheetSiblings", - "parameters": [ - { - "name": "path", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "before", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "after", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SubsetFileSystemTreeItemPresentationModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -29473,32 +29373,20 @@ ] } }, - "/umbraco/management/api/v1/tag": { + "/umbraco/management/api/v1/relation/type/{id}": { "get": { "tags": [ - "Tag" + "Relation" ], - "operationId": "GetTag", + "operationId": "GetRelationByRelationTypeId", "parameters": [ { - "name": "query", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "tagGroup", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "culture", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } }, { @@ -29528,7 +29416,21 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedTagResponseModel" + "$ref": "#/components/schemas/PagedRelationResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedProblemDetailsModel" } ] } @@ -29537,6 +29439,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -29546,29 +29451,22 @@ ] } }, - "/umbraco/management/api/v1/telemetry": { + "/umbraco/management/api/v1/item/script": { "get": { "tags": [ - "Telemetry" + "Script" ], - "operationId": "GetTelemetry", + "operationId": "GetItemScript", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", + "name": "path", "in": "query", "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } } } ], @@ -29578,20 +29476,20 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedTelemetryResponseModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ScriptItemResponseModel" + } + ] + } } } } }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -29601,52 +29499,19 @@ ] } }, - "/umbraco/management/api/v1/telemetry/level": { - "get": { + "/umbraco/management/api/v1/script": { + "post": { "tags": [ - "Telemetry" + "Script" ], - "operationId": "GetTelemetryLevel", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/TelemetryResponseModel" - } - ] - } - } - } - }, - "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": [ ] - } - ] - }, - "post": { - "tags": [ - "Telemetry" - ], - "operationId": "PostTelemetryLevel", + "operationId": "PostScript", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/TelemetryRequestModel" + "$ref": "#/components/schemas/CreateScriptRequestModel" } ] } @@ -29655,7 +29520,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/TelemetryRequestModel" + "$ref": "#/components/schemas/CreateScriptRequestModel" } ] } @@ -29664,7 +29529,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/TelemetryRequestModel" + "$ref": "#/components/schemas/CreateScriptRequestModel" } ] } @@ -29672,6 +29537,36 @@ } }, "responses": { + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "400": { "description": "Bad Request", "headers": { @@ -29698,8 +29593,8 @@ } } }, - "200": { - "description": "OK", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -29711,6 +29606,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "401": { @@ -29739,23 +29645,19 @@ ] } }, - "/umbraco/management/api/v1/item/template": { + "/umbraco/management/api/v1/script/{path}": { "get": { "tags": [ - "Template" + "Script" ], - "operationId": "GetItemTemplate", + "operationId": "GetScriptByPath", "parameters": [ { - "name": "id", - "in": "query", + "name": "path", + "in": "path", + "required": true, "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } + "type": "string" } } ], @@ -29765,71 +29667,23 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/TemplateItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/ScriptResponseModel" + } + ] } } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/item/template/search": { - "get": { - "tags": [ - "Template" - ], - "operationId": "GetItemTemplateSearch", - "parameters": [ - { - "name": "query", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedModelTemplateItemResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -29838,6 +29692,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -29845,64 +29702,26 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/template": { - "post": { + }, + "delete": { "tags": [ - "Template" + "Script" ], - "operationId": "PostTemplate", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateTemplateRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateTemplateRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateTemplateRequestModel" - } - ] - } + "operationId": "DeleteScriptByPath", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string" } } - }, + ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -29991,83 +29810,53 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/template/{id}": { - "get": { + }, + "put": { "tags": [ - "Template" + "Script" ], - "operationId": "GetTemplateById", + "operationId": "PutScriptByPath", "parameters": [ { - "name": "id", + "name": "path", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/TemplateResponseModel" - } - ] - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateScriptRequestModel" + } + ] } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateScriptRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateScriptRequestModel" + } + ] } } - }, - "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": [ ] - } - ] - }, - "delete": { - "tags": [ - "Template" - ], - "operationId": "DeleteTemplateById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], "responses": { "200": { "description": "OK", @@ -30160,20 +29949,21 @@ "Backoffice-User": [ ] } ] - }, + } + }, + "/umbraco/management/api/v1/script/{path}/rename": { "put": { "tags": [ - "Template" + "Script" ], - "operationId": "PutTemplateById", + "operationId": "PutScriptByPathRename", "parameters": [ { - "name": "id", + "name": "path", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], @@ -30183,7 +29973,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateTemplateRequestModel" + "$ref": "#/components/schemas/RenameScriptRequestModel" } ] } @@ -30192,7 +29982,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateTemplateRequestModel" + "$ref": "#/components/schemas/RenameScriptRequestModel" } ] } @@ -30201,7 +29991,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateTemplateRequestModel" + "$ref": "#/components/schemas/RenameScriptRequestModel" } ] } @@ -30209,9 +29999,24 @@ } }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -30302,54 +30107,19 @@ ] } }, - "/umbraco/management/api/v1/template/configuration": { - "get": { - "tags": [ - "Template" - ], - "operationId": "GetTemplateConfiguration", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/TemplateConfigurationResponseModel" - } - ] - } - } - } - }, - "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/template/query/execute": { + "/umbraco/management/api/v1/script/folder": { "post": { "tags": [ - "Template" + "Script" ], - "operationId": "PostTemplateQueryExecute", + "operationId": "PostScriptFolder", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/TemplateQueryExecuteModel" + "$ref": "#/components/schemas/CreateScriptFolderRequestModel" } ] } @@ -30358,7 +30128,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/TemplateQueryExecuteModel" + "$ref": "#/components/schemas/CreateScriptFolderRequestModel" } ] } @@ -30367,7 +30137,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/TemplateQueryExecuteModel" + "$ref": "#/components/schemas/CreateScriptFolderRequestModel" } ] } @@ -30375,8 +30145,38 @@ } }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -30394,7 +30194,33 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/TemplateQueryResultResponseModel" + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -30427,12 +30253,22 @@ ] } }, - "/umbraco/management/api/v1/template/query/settings": { + "/umbraco/management/api/v1/script/folder/{path}": { "get": { "tags": [ - "Template" + "Script" + ], + "operationId": "GetScriptFolderByPath", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } ], - "operationId": "GetTemplateQuerySettings", "responses": { "200": { "description": "OK", @@ -30441,7 +30277,21 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/TemplateQuerySettingsResponseModel" + "$ref": "#/components/schemas/ScriptFolderResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -30460,21 +30310,128 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/tree/template/ancestors": { - "get": { + }, + "delete": { "tags": [ - "Template" + "Script" ], - "operationId": "GetTreeTemplateAncestors", + "operationId": "DeleteScriptFolderByPath", "parameters": [ { - "name": "descendantId", + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/tree/script/ancestors": { + "get": { + "tags": [ + "Script" + ], + "operationId": "GetTreeScriptAncestors", + "parameters": [ + { + "name": "descendantPath", "in": "query", "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], @@ -30488,7 +30445,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/NamedEntityTreeItemResponseModel" + "$ref": "#/components/schemas/FileSystemTreeItemPresentationModel" } ] } @@ -30510,19 +30467,18 @@ ] } }, - "/umbraco/management/api/v1/tree/template/children": { + "/umbraco/management/api/v1/tree/script/children": { "get": { "tags": [ - "Template" + "Script" ], - "operationId": "GetTreeTemplateChildren", + "operationId": "GetTreeScriptChildren", "parameters": [ { - "name": "parentId", + "name": "parentPath", "in": "query", "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, { @@ -30552,7 +30508,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedNamedEntityTreeItemResponseModel" + "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" } ] } @@ -30573,12 +30529,12 @@ ] } }, - "/umbraco/management/api/v1/tree/template/root": { + "/umbraco/management/api/v1/tree/script/root": { "get": { "tags": [ - "Template" + "Script" ], - "operationId": "GetTreeTemplateRoot", + "operationId": "GetTreeScriptRoot", "parameters": [ { "name": "skip", @@ -30607,7 +30563,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedNamedEntityTreeItemResponseModel" + "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" } ] } @@ -30628,19 +30584,18 @@ ] } }, - "/umbraco/management/api/v1/tree/template/siblings": { + "/umbraco/management/api/v1/tree/script/siblings": { "get": { "tags": [ - "Template" + "Script" ], - "operationId": "GetTreeTemplateSiblings", + "operationId": "GetTreeScriptSiblings", "parameters": [ { - "name": "target", + "name": "path", "in": "query", "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, { @@ -30668,7 +30623,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SubsetNamedEntityTreeItemResponseModel" + "$ref": "#/components/schemas/SubsetFileSystemTreeItemPresentationModel" } ] } @@ -30689,94 +30644,41 @@ ] } }, - "/umbraco/management/api/v1/temporary-file": { - "post": { + "/umbraco/management/api/v1/searcher": { + "get": { "tags": [ - "Temporary File" + "Searcher" ], - "operationId": "PostTemporaryFile", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "required": [ - "File", - "Id" - ], - "type": "object", - "properties": { - "Id": { - "type": "string", - "format": "uuid" - }, - "File": { - "type": "string", - "format": "binary" - } - } - }, - "encoding": { - "Id": { - "style": "form" - }, - "File": { - "style": "form" - } - } + "operationId": "GetSearcher", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } - }, + ], "responses": { - "201": { - "description": "Created", - "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedSearcherResponseModel" } ] } @@ -30794,32 +30696,56 @@ ] } }, - "/umbraco/management/api/v1/temporary-file/{id}": { + "/umbraco/management/api/v1/searcher/{searcherName}/query": { "get": { "tags": [ - "Temporary File" + "Searcher" ], - "operationId": "GetTemporaryFileById", + "operationId": "GetSearcherBySearcherNameQuery", "parameters": [ { - "name": "id", + "name": "searcherName", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" + } + }, + { + "name": "term", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } ], "responses": { - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedSearchResultResponseModel" } ] } @@ -30840,6 +30766,24 @@ } } }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/security/configuration": { + "get": { + "tags": [ + "Security" + ], + "operationId": "GetSecurityConfiguration", + "responses": { "200": { "description": "OK", "content": { @@ -30847,7 +30791,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/TemporaryFileResponseModel" + "$ref": "#/components/schemas/SecurityConfigurationResponseModel" } ] } @@ -30856,6 +30800,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -30863,26 +30810,48 @@ "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/security/forgot-password": { + "post": { "tags": [ - "Temporary File" + "Security" ], - "operationId": "DeleteTemporaryFileById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" + "operationId": "PostSecurityForgotPassword", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordRequestModel" + } + ] + } } } - ], + }, "responses": { - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -30894,21 +30863,10 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -30933,8 +30891,11 @@ } } }, - "200": { - "description": "OK", + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -30947,9 +30908,6 @@ } } } - }, - "401": { - "description": "The resource is protected and requires an authentication token" } }, "security": [ @@ -30959,47 +30917,46 @@ ] } }, - "/umbraco/management/api/v1/temporary-file/configuration": { - "get": { + "/umbraco/management/api/v1/security/forgot-password/reset": { + "post": { "tags": [ - "Temporary File" + "Security" ], - "operationId": "GetTemporaryFileConfiguration", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/TemporaryFileConfigurationResponseModel" - } - ] - } + "operationId": "PostSecurityForgotPasswordReset", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordTokenRequestModel" + } + ] } } - }, - "401": { - "description": "The resource is protected and requires an authentication token" } }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/upgrade/authorize": { - "post": { - "tags": [ - "Upgrade" - ], - "operationId": "PostUpgradeAuthorize", "responses": { - "200": { - "description": "OK", + "204": { + "description": "No Content", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -31013,8 +30970,8 @@ } } }, - "428": { - "description": "Precondition Required", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -31032,15 +30989,15 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" } ] } } } }, - "500": { - "description": "Internal Server Error", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -31058,7 +31015,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" } ] } @@ -31091,77 +31048,28 @@ ] } }, - "/umbraco/management/api/v1/upgrade/settings": { - "get": { + "/umbraco/management/api/v1/security/forgot-password/verify": { + "post": { "tags": [ - "Upgrade" + "Security" ], - "operationId": "GetUpgradeSettings", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpgradeSettingsResponseModel" - } - ] - } - } - } - }, - "428": { - "description": "Precondition Required", - "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/user-data": { - "post": { - "tags": [ - "User Data" - ], - "operationId": "PostUserData", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateUserDataRequestModel" - } - ] + "operationId": "PostSecurityForgotPasswordVerify", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/VerifyResetPasswordTokenRequestModel" + } + ] } }, "text/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateUserDataRequestModel" + "$ref": "#/components/schemas/VerifyResetPasswordTokenRequestModel" } ] } @@ -31170,7 +31078,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateUserDataRequestModel" + "$ref": "#/components/schemas/VerifyResetPasswordTokenRequestModel" } ] } @@ -31178,24 +31086,9 @@ } }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -31206,6 +31099,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/VerifyResetPasswordResponseModel" + } + ] + } + } } }, "400": { @@ -31225,7 +31129,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserDataOperationStatusModel" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] } } } @@ -31247,47 +31155,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserDataOperationStatusModel" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsBuilderModel" + } + ] } } } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] } - ] - }, + } + } + }, + "/umbraco/management/api/v1/segment": { "get": { "tags": [ - "User Data" + "Segment" ], - "operationId": "GetUserData", + "operationId": "GetSegment", "parameters": [ - { - "name": "groups", - "in": "query", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - { - "name": "identifiers", - "in": "query", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, { "name": "skip", "in": "query", @@ -31315,7 +31201,21 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedUserDataResponseModel" + "$ref": "#/components/schemas/PagedSegmentResponseModel" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -31331,99 +31231,119 @@ "Backoffice-User": [ ] } ] - }, - "put": { + } + }, + "/umbraco/management/api/v1/server/configuration": { + "get": { "tags": [ - "User Data" + "Server" ], - "operationId": "PutUserData", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpdateUserDataRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpdateUserDataRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpdateUserDataRequestModel" - } - ] + "operationId": "GetServerConfiguration", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ServerConfigurationResponseModel" + } + ] + } } } } - }, + } + } + }, + "/umbraco/management/api/v1/server/information": { + "get": { + "tags": [ + "Server" + ], + "operationId": "GetServerInformation", "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "content": { + "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/ServerInformationResponseModel" + } + ] } } } }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/server/status": { + "get": { + "tags": [ + "Server" + ], + "operationId": "GetServerStatus", + "responses": { "400": { "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserDataOperationStatusModel" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] } } } }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/ServerStatusResponseModel" + } + ] } } - }, + } + } + } + } + }, + "/umbraco/management/api/v1/server/troubleshooting": { + "get": { + "tags": [ + "Server" + ], + "operationId": "GetServerTroubleshooting", + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserDataOperationStatusModel" + "oneOf": [ + { + "$ref": "#/components/schemas/ServerTroubleshootingResponseModel" + } + ] } } } @@ -31439,23 +31359,12 @@ ] } }, - "/umbraco/management/api/v1/user-data/{id}": { + "/umbraco/management/api/v1/server/upgrade-check": { "get": { "tags": [ - "User Data" - ], - "operationId": "GetUserDataById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Server" ], + "operationId": "GetServerUpgradeCheck", "responses": { "200": { "description": "OK", @@ -31464,98 +31373,105 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UserDataModel" + "$ref": "#/components/schemas/UpgradeCheckResponseModel" } ] } } } }, - "404": { - "description": "Not Found" - }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, + "deprecated": true, "security": [ { "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/item/static-file": { + "get": { "tags": [ - "User Data" + "Static File" ], - "operationId": "DeleteUserDataById", + "operationId": "GetItemStaticFile", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "path", + "in": "query", "schema": { - "type": "string", - "format": "uuid" + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } } } ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "content": { + "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/StaticFileItemResponseModel" + } + ] + } } } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/tree/static-file/ancestors": { + "get": { + "tags": [ + "Static File" + ], + "operationId": "GetTreeStaticFileAncestors", + "parameters": [ + { + "name": "descendantPath", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDataOperationStatusModel" - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDataOperationStatusModel" + "oneOf": [ + { + "$ref": "#/components/schemas/FileSystemTreeItemPresentationModel" + } + ] + } } } } @@ -31571,13 +31487,20 @@ ] } }, - "/umbraco/management/api/v1/filter/user-group": { + "/umbraco/management/api/v1/tree/static-file/children": { "get": { "tags": [ - "User Group" + "Static File" ], - "operationId": "GetFilterUserGroup", + "operationId": "GetTreeStaticFileChildren", "parameters": [ + { + "name": "parentPath", + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "skip", "in": "query", @@ -31595,14 +31518,6 @@ "format": "int32", "default": 100 } - }, - { - "name": "filter", - "in": "query", - "schema": { - "type": "string", - "default": "" - } } ], "responses": { @@ -31613,35 +31528,59 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedUserGroupResponseModel" + "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" } ] } } } }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/tree/static-file/root": { + "get": { + "tags": [ + "Static File" + ], + "operationId": "GetTreeStaticFileRoot", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 } }, - "404": { - "description": "Not Found", + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" } ] } @@ -31650,9 +31589,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -31662,22 +31598,21 @@ ] } }, - "/umbraco/management/api/v1/item/user-group": { + "/umbraco/management/api/v1/item/stylesheet": { "get": { "tags": [ - "User Group" + "Stylesheet" ], - "operationId": "GetItemUserGroup", + "operationId": "GetItemStylesheet", "parameters": [ { - "name": "id", + "name": "path", "in": "query", "schema": { "uniqueItems": true, "type": "array", "items": { - "type": "string", - "format": "uuid" + "type": "string" } } } @@ -31692,7 +31627,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/UserGroupItemResponseModel" + "$ref": "#/components/schemas/StylesheetItemResponseModel" } ] } @@ -31711,19 +31646,19 @@ ] } }, - "/umbraco/management/api/v1/user-group": { - "delete": { + "/umbraco/management/api/v1/stylesheet": { + "post": { "tags": [ - "User Group" + "Stylesheet" ], - "operationId": "DeleteUserGroup", + "operationId": "PostStylesheet", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/DeleteUserGroupsRequestModel" + "$ref": "#/components/schemas/CreateStylesheetRequestModel" } ] } @@ -31732,7 +31667,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/DeleteUserGroupsRequestModel" + "$ref": "#/components/schemas/CreateStylesheetRequestModel" } ] } @@ -31741,7 +31676,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/DeleteUserGroupsRequestModel" + "$ref": "#/components/schemas/CreateStylesheetRequestModel" } ] } @@ -31749,9 +31684,24 @@ } }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -31764,8 +31714,8 @@ } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -31790,70 +31740,8 @@ } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - }, - "post": { - "tags": [ - "User Group" - ], - "operationId": "PostUserGroup", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateUserGroupRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateUserGroupRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateUserGroupRequestModel" - } - ] - } - } - } - }, - "responses": { - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -31878,36 +31766,6 @@ } } }, - "201": { - "description": "Created", - "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -31932,75 +31790,21 @@ "Backoffice-User": [ ] } ] - }, - "get": { - "tags": [ - "User Group" - ], - "operationId": "GetUserGroup", - "parameters": [ - { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PagedUserGroupResponseModel" - } - ] - } - } - } - }, - "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/user-group/{id}": { + "/umbraco/management/api/v1/stylesheet/{path}": { "get": { "tags": [ - "User Group" + "Stylesheet" ], - "operationId": "GetUserGroupById", + "operationId": "GetStylesheetByPath", "parameters": [ { - "name": "id", + "name": "path", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], @@ -32012,7 +31816,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UserGroupResponseModel" + "$ref": "#/components/schemas/StylesheetResponseModel" } ] } @@ -32048,17 +31852,16 @@ }, "delete": { "tags": [ - "User Group" + "Stylesheet" ], - "operationId": "DeleteUserGroupById", + "operationId": "DeleteStylesheetByPath", "parameters": [ { - "name": "id", + "name": "path", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], @@ -32078,6 +31881,32 @@ } } }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "404": { "description": "Not Found", "headers": { @@ -32131,17 +31960,16 @@ }, "put": { "tags": [ - "User Group" + "Stylesheet" ], - "operationId": "PutUserGroupById", + "operationId": "PutStylesheetByPath", "parameters": [ { - "name": "id", + "name": "path", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], @@ -32151,7 +31979,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateUserGroupRequestModel" + "$ref": "#/components/schemas/UpdateStylesheetRequestModel" } ] } @@ -32160,7 +31988,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateUserGroupRequestModel" + "$ref": "#/components/schemas/UpdateStylesheetRequestModel" } ] } @@ -32169,7 +31997,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateUserGroupRequestModel" + "$ref": "#/components/schemas/UpdateStylesheetRequestModel" } ] } @@ -32192,6 +32020,32 @@ } } }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "404": { "description": "Not Found", "headers": { @@ -32244,20 +32098,19 @@ ] } }, - "/umbraco/management/api/v1/user-group/{id}/users": { - "delete": { + "/umbraco/management/api/v1/stylesheet/{path}/rename": { + "put": { "tags": [ - "User Group" + "Stylesheet" ], - "operationId": "DeleteUserGroupByIdUsers", + "operationId": "PutStylesheetByPathRename", "parameters": [ { - "name": "id", + "name": "path", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], @@ -32265,45 +32118,66 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/RenameStylesheetRequestModel" + } + ] } }, "text/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/RenameStylesheetRequestModel" + } + ] } }, "application/*+json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/RenameStylesheetRequestModel" + } + ] } } } }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -32315,6 +32189,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "404": { @@ -32367,66 +32252,78 @@ "Backoffice-User": [ ] } ] - }, + } + }, + "/umbraco/management/api/v1/stylesheet/folder": { "post": { "tags": [ - "User Group" - ], - "operationId": "PostUserGroupByIdUsers", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Stylesheet" ], + "operationId": "PostStylesheetFolder", "requestBody": { "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/CreateStylesheetFolderRequestModel" + } + ] } }, "text/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/CreateStylesheetFolderRequestModel" + } + ] } }, "application/*+json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/CreateStylesheetFolderRequestModel" + } + ] } } } }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -32438,6 +32335,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "404": { @@ -32492,74 +32400,19 @@ ] } }, - "/umbraco/management/api/v1/filter/user": { + "/umbraco/management/api/v1/stylesheet/folder/{path}": { "get": { "tags": [ - "User" + "Stylesheet" ], - "operationId": "GetFilterUser", + "operationId": "GetStylesheetFolderByPath", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "take", - "in": "query", + "name": "path", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 100 - } - }, - { - "name": "orderBy", - "in": "query", - "schema": { - "$ref": "#/components/schemas/UserOrderModel" - } - }, - { - "name": "orderDirection", - "in": "query", - "schema": { - "$ref": "#/components/schemas/DirectionModel" - } - }, - { - "name": "userGroupIds", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - }, - { - "name": "userStates", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/UserStateModel" - } - } - }, - { - "name": "filter", - "in": "query", - "schema": { - "type": "string", - "default": "" + "type": "string" } } ], @@ -32571,21 +32424,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedUserResponseModel" - } - ] - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/StylesheetFolderResponseModel" } ] } @@ -32618,113 +32457,26 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/item/user": { - "get": { + }, + "delete": { "tags": [ - "User" + "Stylesheet" ], - "operationId": "GetItemUser", + "operationId": "DeleteStylesheetFolderByPath", "parameters": [ { - "name": "id", - "in": "query", + "name": "path", + "in": "path", + "required": true, "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } + "type": "string" } } ], "responses": { "200": { "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserItemResponseModel" - } - ] - } - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/user": { - "post": { - "tags": [ - "User" - ], - "operationId": "PostUser", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateUserRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateUserRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateUserRequestModel" - } - ] - } - } - } - }, - "responses": { - "201": { - "description": "Created", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -32813,79 +32565,97 @@ "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/tree/stylesheet/ancestors": { + "get": { "tags": [ - "User" + "Stylesheet" ], - "operationId": "DeleteUser", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/DeleteUsersRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/DeleteUsersRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/DeleteUsersRequestModel" - } - ] - } + "operationId": "GetTreeStylesheetAncestors", + "parameters": [ + { + "name": "descendantPath", + "in": "query", + "schema": { + "type": "string" } } - }, + ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "content": { + "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/FileSystemTreeItemPresentationModel" + } + ] + } } } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, + "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/tree/stylesheet/children": { + "get": { + "tags": [ + "Stylesheet" + ], + "operationId": "GetTreeStylesheetChildren", + "parameters": [ + { + "name": "parentPath", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" } ] } @@ -32896,19 +32666,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -32916,12 +32674,14 @@ "Backoffice-User": [ ] } ] - }, + } + }, + "/umbraco/management/api/v1/tree/stylesheet/root": { "get": { "tags": [ - "User" + "Stylesheet" ], - "operationId": "GetUser", + "operationId": "GetTreeStylesheetRoot", "parameters": [ { "name": "skip", @@ -32950,21 +32710,67 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedUserResponseModel" + "$ref": "#/components/schemas/PagedFileSystemTreeItemPresentationModel" } ] } } } }, - "404": { - "description": "Not Found", + "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/tree/stylesheet/siblings": { + "get": { + "tags": [ + "Stylesheet" + ], + "operationId": "GetTreeStylesheetSiblings", + "parameters": [ + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/SubsetFileSystemTreeItemPresentationModel" } ] } @@ -32985,20 +32791,50 @@ ] } }, - "/umbraco/management/api/v1/user/{id}": { + "/umbraco/management/api/v1/tag": { "get": { "tags": [ - "User" + "Tag" ], - "operationId": "GetUserById", + "operationId": "GetTag", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "query", + "in": "query", "schema": { - "type": "string", - "format": "uuid" + "type": "string" + } + }, + { + "name": "tagGroup", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "culture", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } ], @@ -33010,21 +32846,59 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UserResponseModel" + "$ref": "#/components/schemas/PagedTagResponseModel" } ] } } } }, - "404": { - "description": "Not Found", + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/telemetry": { + "get": { + "tags": [ + "Telemetry" + ], + "operationId": "GetTelemetry", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedTelemetryResponseModel" } ] } @@ -33043,85 +32917,23 @@ "Backoffice-User": [ ] } ] - }, - "delete": { + } + }, + "/umbraco/management/api/v1/telemetry/level": { + "get": { "tags": [ - "User" - ], - "operationId": "DeleteUserById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Telemetry" ], + "operationId": "GetTelemetryLevel", "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/TelemetryResponseModel" } ] } @@ -33132,19 +32944,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -33153,29 +32953,18 @@ } ] }, - "put": { + "post": { "tags": [ - "User" - ], - "operationId": "PutUserById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Telemetry" ], + "operationId": "PostTelemetryLevel", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateUserRequestModel" + "$ref": "#/components/schemas/TelemetryRequestModel" } ] } @@ -33184,7 +32973,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateUserRequestModel" + "$ref": "#/components/schemas/TelemetryRequestModel" } ] } @@ -33193,7 +32982,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateUserRequestModel" + "$ref": "#/components/schemas/TelemetryRequestModel" } ] } @@ -33201,21 +32990,6 @@ } }, "responses": { - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "400": { "description": "Bad Request", "headers": { @@ -33242,8 +33016,8 @@ } } }, - "404": { - "description": "Not Found", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -33255,17 +33029,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "401": { @@ -33294,20 +33057,23 @@ ] } }, - "/umbraco/management/api/v1/user/{id}/2fa": { + "/umbraco/management/api/v1/item/template": { "get": { "tags": [ - "User" + "Template" ], - "operationId": "GetUserById2fa", + "operationId": "GetItemTemplate", "parameters": [ { "name": "id", - "in": "path", - "required": true, + "in": "query", "schema": { - "type": "string", - "format": "uuid" + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } } } ], @@ -33321,7 +33087,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/UserTwoFactorProviderModel" + "$ref": "#/components/schemas/TemplateItemResponseModel" } ] } @@ -33329,14 +33095,59 @@ } } }, - "404": { - "description": "Not Found", + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/item/template/search": { + "get": { + "tags": [ + "Template" + ], + "operationId": "GetItemTemplateSearch", + "parameters": [ + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedModelTemplateItemResponseModel" } ] } @@ -33345,9 +33156,6 @@ }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -33357,35 +33165,62 @@ ] } }, - "/umbraco/management/api/v1/user/{id}/2fa/{providerName}": { - "delete": { + "/umbraco/management/api/v1/template": { + "post": { "tags": [ - "User" + "Template" ], - "operationId": "DeleteUserById2faByProviderName", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "providerName", - "in": "path", - "required": true, - "schema": { - "type": "string" + "operationId": "PostTemplate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateTemplateRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateTemplateRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateTemplateRequestModel" + } + ] + } } } - ], + }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -33398,8 +33233,8 @@ } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -33424,8 +33259,8 @@ } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -33476,12 +33311,12 @@ ] } }, - "/umbraco/management/api/v1/user/{id}/calculate-start-nodes": { + "/umbraco/management/api/v1/template/{id}": { "get": { "tags": [ - "User" + "Template" ], - "operationId": "GetUserByIdCalculateStartNodes", + "operationId": "GetTemplateById", "parameters": [ { "name": "id", @@ -33501,7 +33336,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CalculatedUserStartNodesResponseModel" + "$ref": "#/components/schemas/TemplateResponseModel" } ] } @@ -33534,14 +33369,12 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/user/{id}/change-password": { - "post": { + }, + "delete": { "tags": [ - "User" + "Template" ], - "operationId": "PostUserByIdChangePassword", + "operationId": "DeleteTemplateById", "parameters": [ { "name": "id", @@ -33553,37 +33386,6 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ChangePasswordUserRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ChangePasswordUserRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ChangePasswordUserRequestModel" - } - ] - } - } - } - }, "responses": { "200": { "description": "OK", @@ -33600,8 +33402,8 @@ } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -33626,8 +33428,8 @@ } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -33676,14 +33478,12 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/user/{id}/client-credentials": { - "post": { + }, + "put": { "tags": [ - "User" + "Template" ], - "operationId": "PostUserByIdClientCredentials", + "operationId": "PutTemplateById", "parameters": [ { "name": "id", @@ -33701,7 +33501,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateUserClientCredentialsRequestModel" + "$ref": "#/components/schemas/UpdateTemplateRequestModel" } ] } @@ -33710,7 +33510,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateUserClientCredentialsRequestModel" + "$ref": "#/components/schemas/UpdateTemplateRequestModel" } ] } @@ -33719,7 +33519,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateUserClientCredentialsRequestModel" + "$ref": "#/components/schemas/UpdateTemplateRequestModel" } ] } @@ -33768,6 +33568,32 @@ } } }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -33792,33 +33618,25 @@ "Backoffice-User": [ ] } ] - }, + } + }, + "/umbraco/management/api/v1/template/configuration": { "get": { "tags": [ - "User" - ], - "operationId": "GetUserByIdClientCredentials", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Template" ], + "operationId": "GetTemplateConfiguration", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "string" - } + "oneOf": [ + { + "$ref": "#/components/schemas/TemplateConfigurationResponseModel" + } + ] } } } @@ -33837,49 +33655,46 @@ ] } }, - "/umbraco/management/api/v1/user/{id}/client-credentials/{clientId}": { - "delete": { + "/umbraco/management/api/v1/template/query/execute": { + "post": { "tags": [ - "User" + "Template" ], - "operationId": "DeleteUserByIdClientCredentialsByClientId", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "name": "clientId", - "in": "path", - "required": true, - "schema": { - "type": "string" + "operationId": "PostTemplateQueryExecute", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/TemplateQueryExecuteModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/TemplateQueryExecuteModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/TemplateQueryExecuteModel" + } + ] + } } } - ], + }, "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "400": { - "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -33897,7 +33712,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/TemplateQueryResultResponseModel" } ] } @@ -33930,96 +33745,21 @@ ] } }, - "/umbraco/management/api/v1/user/{id}/reset-password": { - "post": { + "/umbraco/management/api/v1/template/query/settings": { + "get": { "tags": [ - "User" - ], - "operationId": "PostUserByIdResetPassword", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "Template" ], + "operationId": "GetTemplateQuerySettings", "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ResetPasswordUserResponseModel" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/TemplateQuerySettingsResponseModel" } ] } @@ -34030,19 +33770,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -34052,17 +33780,16 @@ ] } }, - "/umbraco/management/api/v1/user/avatar/{id}": { - "delete": { + "/umbraco/management/api/v1/tree/template/ancestors": { + "get": { "tags": [ - "User" + "Template" ], - "operationId": "DeleteUserAvatarById", + "operationId": "GetTreeTemplateAncestors", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "descendantId", + "in": "query", "schema": { "type": "string", "format": "uuid" @@ -34072,67 +33799,17 @@ "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] + "oneOf": [ + { + "$ref": "#/components/schemas/NamedEntityTreeItemResponseModel" + } + ] + } } } } @@ -34141,19 +33818,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -34161,116 +33826,51 @@ "Backoffice-User": [ ] } ] - }, - "post": { + } + }, + "/umbraco/management/api/v1/tree/template/children": { + "get": { "tags": [ - "User" + "Template" ], - "operationId": "PostUserAvatarById", + "operationId": "GetTreeTemplateChildren", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "parentId", + "in": "query", "schema": { "type": "string", "format": "uuid" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/SetAvatarRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/SetAvatarRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/SetAvatarRequestModel" - } - ] - } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } - }, + ], "responses": { "200": { "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedNamedEntityTreeItemResponseModel" } ] } @@ -34281,19 +33881,7 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -34303,12 +33891,32 @@ ] } }, - "/umbraco/management/api/v1/user/configuration": { + "/umbraco/management/api/v1/tree/template/root": { "get": { "tags": [ - "User" + "Template" + ], + "operationId": "GetTreeTemplateRoot", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } ], - "operationId": "GetUserConfiguration", "responses": { "200": { "description": "OK", @@ -34317,7 +33925,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UserConfigurationResponseModel" + "$ref": "#/components/schemas/PagedNamedEntityTreeItemResponseModel" } ] } @@ -34338,12 +33946,38 @@ ] } }, - "/umbraco/management/api/v1/user/current": { + "/umbraco/management/api/v1/tree/template/siblings": { "get": { "tags": [ - "User" + "Template" + ], + "operationId": "GetTreeTemplateSiblings", + "parameters": [ + { + "name": "target", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } ], - "operationId": "GetUserCurrent", "responses": { "200": { "description": "OK", @@ -34352,7 +33986,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CurrentUserResponseModel" + "$ref": "#/components/schemas/SubsetNamedEntityTreeItemResponseModel" } ] } @@ -34361,6 +33995,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -34370,26 +34007,96 @@ ] } }, - "/umbraco/management/api/v1/user/current/2fa": { - "get": { + "/umbraco/management/api/v1/temporary-file": { + "post": { "tags": [ - "User" + "Temporary File" ], - "operationId": "GetUserCurrent2fa", + "operationId": "PostTemporaryFile", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "required": [ + "File", + "Id" + ], + "type": "object", + "properties": { + "Id": { + "type": "string", + "format": "uuid" + }, + "File": { + "type": "string", + "format": "binary" + } + } + }, + "encoding": { + "Id": { + "style": "form" + }, + "File": { + "style": "form" + } + } + } + } + }, "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserTwoFactorProviderModel" - } - ] - } + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] } } } @@ -34405,32 +34112,95 @@ ] } }, - "/umbraco/management/api/v1/user/current/2fa/{providerName}": { - "delete": { + "/umbraco/management/api/v1/temporary-file/{id}": { + "get": { "tags": [ - "User" + "Temporary File" ], - "operationId": "DeleteUserCurrent2faByProviderName", + "operationId": "GetTemporaryFileById", "parameters": [ { - "name": "providerName", + "name": "id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "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" + } + ] + } + } + } + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/TemporaryFileResponseModel" + } + ] + } + } } }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ { - "name": "code", - "in": "query", + "Backoffice-User": [ ] + } + ] + }, + "delete": { + "tags": [ + "Temporary File" + ], + "operationId": "DeleteTemporaryFileById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { - "200": { - "description": "OK", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -34442,6 +34212,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "404": { @@ -34470,8 +34251,8 @@ } } }, - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -34483,13 +34264,34 @@ "nullable": true } } - }, + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/temporary-file/configuration": { + "get": { + "tags": [ + "Temporary File" + ], + "operationId": "GetTemporaryFileConfiguration", + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/TemporaryFileConfigurationResponseModel" } ] } @@ -34505,53 +34307,14 @@ "Backoffice-User": [ ] } ] - }, + } + }, + "/umbraco/management/api/v1/upgrade/authorize": { "post": { "tags": [ - "User" - ], - "operationId": "PostUserCurrent2faByProviderName", - "parameters": [ - { - "name": "providerName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } + "Upgrade" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/EnableTwoFactorRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/EnableTwoFactorRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/EnableTwoFactorRequestModel" - } - ] - } - } - } - }, + "operationId": "PostUpgradeAuthorize", "responses": { "200": { "description": "OK", @@ -34566,21 +34329,10 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/NoopSetupTwoFactorModel" - } - ] - } - } } }, - "400": { - "description": "Bad Request", + "428": { + "description": "Precondition Required", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -34605,8 +34357,8 @@ } } }, - "404": { - "description": "Not Found", + "500": { + "description": "Internal Server Error", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -34633,6 +34385,21 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -34640,22 +34407,14 @@ "Backoffice-User": [ ] } ] - }, + } + }, + "/umbraco/management/api/v1/upgrade/settings": { "get": { "tags": [ - "User" - ], - "operationId": "GetUserCurrent2faByProviderName", - "parameters": [ - { - "name": "providerName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } + "Upgrade" ], + "operationId": "GetUpgradeSettings", "responses": { "200": { "description": "OK", @@ -34664,29 +34423,15 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/NoopSetupTwoFactorModel" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/UpgradeSettingsResponseModel" } ] } } } }, - "400": { - "description": "Bad Request", + "428": { + "description": "Precondition Required", "content": { "application/json": { "schema": { @@ -34701,6 +34446,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -34710,19 +34458,19 @@ ] } }, - "/umbraco/management/api/v1/user/current/avatar": { + "/umbraco/management/api/v1/user-data": { "post": { "tags": [ - "User" + "User Data" ], - "operationId": "PostUserCurrentAvatar", + "operationId": "PostUserData", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SetAvatarRequestModel" + "$ref": "#/components/schemas/CreateUserDataRequestModel" } ] } @@ -34731,7 +34479,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SetAvatarRequestModel" + "$ref": "#/components/schemas/CreateUserDataRequestModel" } ] } @@ -34740,7 +34488,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/SetAvatarRequestModel" + "$ref": "#/components/schemas/CreateUserDataRequestModel" } ] } @@ -34748,9 +34496,24 @@ } }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -34777,12 +34540,100 @@ } } }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDataOperationStatusModel" + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDataOperationStatusModel" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + }, + "get": { + "tags": [ + "User Data" + ], + "operationId": "GetUserData", + "parameters": [ + { + "name": "groups", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "identifiers", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/PagedUserDataResponseModel" } ] } @@ -34798,21 +34649,19 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/user/current/change-password": { - "post": { + }, + "put": { "tags": [ - "User" + "User Data" ], - "operationId": "PostUserCurrentChangePassword", + "operationId": "PutUserData", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" + "$ref": "#/components/schemas/UpdateUserDataRequestModel" } ] } @@ -34821,7 +34670,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" + "$ref": "#/components/schemas/UpdateUserDataRequestModel" } ] } @@ -34830,7 +34679,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" + "$ref": "#/components/schemas/UpdateUserDataRequestModel" } ] } @@ -34870,11 +34719,29 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] + "$ref": "#/components/schemas/UserDataOperationStatusModel" + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDataOperationStatusModel" } } } @@ -34890,12 +34757,23 @@ ] } }, - "/umbraco/management/api/v1/user/current/configuration": { + "/umbraco/management/api/v1/user-data/{id}": { "get": { "tags": [ - "User" + "User Data" + ], + "operationId": "GetUserDataById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "GetUserCurrentConfiguration", "responses": { "200": { "description": "OK", @@ -34904,18 +34782,18 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CurrentUserConfigurationResponseModel" + "$ref": "#/components/schemas/UserDataModel" } ] } } } }, + "404": { + "description": "Not Found" + }, "401": { "description": "The resource is protected and requires an authentication token" - }, - "403": { - "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -34923,28 +34801,79 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/user/current/login-providers": { - "get": { + }, + "delete": { "tags": [ - "User" + "User Data" + ], + "operationId": "DeleteUserDataById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "GetUserCurrentLoginProviders", "responses": { "200": { "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDataOperationStatusModel" + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", "schema": { "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserExternalLoginProviderModel" - } - ] - } + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDataOperationStatusModel" } } } @@ -34960,23 +34889,37 @@ ] } }, - "/umbraco/management/api/v1/user/current/permissions": { + "/umbraco/management/api/v1/filter/user-group": { "get": { "tags": [ - "User" + "User Group" ], - "operationId": "GetUserCurrentPermissions", + "operationId": "GetFilterUserGroup", "parameters": [ { - "name": "id", + "name": "skip", "in": "query", "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "filter", + "in": "query", + "schema": { + "type": "string", + "default": "" } } ], @@ -34988,15 +34931,15 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UserPermissionsResponseModel" + "$ref": "#/components/schemas/PagedUserGroupResponseModel" } ] } } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "content": { "application/json": { "schema": { @@ -35009,55 +34952,6 @@ } } }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice-User": [ ] - } - ] - } - }, - "/umbraco/management/api/v1/user/current/permissions/document": { - "get": { - "tags": [ - "User" - ], - "operationId": "GetUserCurrentPermissionsDocument", - "parameters": [ - { - "name": "id", - "in": "query", - "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserPermissionsResponseModel" - } - ] - } - } - } - } - }, "404": { "description": "Not Found", "content": { @@ -35074,6 +34968,9 @@ }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" } }, "security": [ @@ -35083,12 +34980,12 @@ ] } }, - "/umbraco/management/api/v1/user/current/permissions/media": { + "/umbraco/management/api/v1/item/user-group": { "get": { "tags": [ - "User" + "User Group" ], - "operationId": "GetUserCurrentPermissionsMedia", + "operationId": "GetItemUserGroup", "parameters": [ { "name": "id", @@ -35109,25 +35006,14 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserPermissionsResponseModel" - } - ] - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserGroupItemResponseModel" + } + ] + } } } } @@ -35143,19 +35029,19 @@ ] } }, - "/umbraco/management/api/v1/user/disable": { - "post": { + "/umbraco/management/api/v1/user-group": { + "delete": { "tags": [ - "User" + "User Group" ], - "operationId": "PostUserDisable", + "operationId": "DeleteUserGroup", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/DisableUserRequestModel" + "$ref": "#/components/schemas/DeleteUserGroupsRequestModel" } ] } @@ -35164,7 +35050,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/DisableUserRequestModel" + "$ref": "#/components/schemas/DeleteUserGroupsRequestModel" } ] } @@ -35173,7 +35059,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/DisableUserRequestModel" + "$ref": "#/components/schemas/DeleteUserGroupsRequestModel" } ] } @@ -35196,32 +35082,6 @@ } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, "404": { "description": "Not Found", "headers": { @@ -35272,21 +35132,19 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/user/enable": { + }, "post": { "tags": [ - "User" + "User Group" ], - "operationId": "PostUserEnable", + "operationId": "PostUserGroup", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/EnableUserRequestModel" + "$ref": "#/components/schemas/CreateUserGroupRequestModel" } ] } @@ -35295,7 +35153,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/EnableUserRequestModel" + "$ref": "#/components/schemas/CreateUserGroupRequestModel" } ] } @@ -35304,7 +35162,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/EnableUserRequestModel" + "$ref": "#/components/schemas/CreateUserGroupRequestModel" } ] } @@ -35312,21 +35170,6 @@ } }, "responses": { - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "400": { "description": "Bad Request", "headers": { @@ -35353,9 +35196,24 @@ } } }, - "404": { - "description": "Not Found", + "201": { + "description": "Created", "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -35366,17 +35224,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "401": { @@ -35403,78 +35250,139 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/user/invite": { - "post": { + }, + "get": { "tags": [ - "User" + "User Group" ], - "operationId": "PostUserInvite", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/InviteUserRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/InviteUserRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/InviteUserRequestModel" - } - ] - } + "operationId": "GetUserGroup", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 } } - }, + ], "responses": { - "201": { - "description": "Created", - "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { - "type": "string", - "description": "Identifier of the newly created resource" + "oneOf": [ + { + "$ref": "#/components/schemas/PagedUserGroupResponseModel" + } + ] } - }, - "Location": { - "description": "Location of the newly created resource", + } + } + }, + "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/user-group/{id}": { + "get": { + "tags": [ + "User Group" + ], + "operationId": "GetUserGroupById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" + "oneOf": [ + { + "$ref": "#/components/schemas/UserGroupResponseModel" + } + ] } - }, - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] } } } }, - "400": { - "description": "Bad Request", + "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": [ ] + } + ] + }, + "delete": { + "tags": [ + "User Group" + ], + "operationId": "DeleteUserGroupById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -35486,17 +35394,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "404": { @@ -35549,21 +35446,30 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/user/invite/create-password": { - "post": { + }, + "put": { "tags": [ - "User" + "User Group" + ], + "operationId": "PutUserGroupById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "PostUserInviteCreatePassword", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateInitialPasswordUserRequestModel" + "$ref": "#/components/schemas/UpdateUserGroupRequestModel" } ] } @@ -35572,7 +35478,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateInitialPasswordUserRequestModel" + "$ref": "#/components/schemas/UpdateUserGroupRequestModel" } ] } @@ -35581,7 +35487,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateInitialPasswordUserRequestModel" + "$ref": "#/components/schemas/UpdateUserGroupRequestModel" } ] } @@ -35630,31 +35536,8 @@ } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "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", @@ -35671,42 +35554,67 @@ } } } - } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] } }, - "/umbraco/management/api/v1/user/invite/resend": { - "post": { + "/umbraco/management/api/v1/user-group/{id}/users": { + "delete": { "tags": [ - "User" + "User Group" + ], + "operationId": "DeleteUserGroupByIdUsers", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "PostUserInviteResend", "requestBody": { "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ResendInviteUserRequestModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } } }, "text/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ResendInviteUserRequestModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } } }, "application/*+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ResendInviteUserRequestModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } } } } @@ -35727,32 +35635,6 @@ } } }, - "400": { - "description": "Bad Request", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } - } - }, "404": { "description": "Not Found", "headers": { @@ -35803,41 +35685,59 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/user/invite/verify": { + }, "post": { "tags": [ - "User" + "User Group" + ], + "operationId": "PostUserGroupByIdUsers", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "PostUserInviteVerify", "requestBody": { "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/VerifyInviteUserRequestModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } } }, "text/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/VerifyInviteUserRequestModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } } }, "application/*+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/VerifyInviteUserRequestModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } } } } @@ -35856,17 +35756,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/VerifyInviteUserResponseModel" - } - ] - } - } } }, "404": { @@ -35895,8 +35784,11 @@ } } }, - "400": { - "description": "Bad Request", + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -35908,7 +35800,118 @@ "nullable": true } } - }, + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/filter/user": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetFilterUser", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "orderBy", + "in": "query", + "schema": { + "$ref": "#/components/schemas/UserOrderModel" + } + }, + { + "name": "orderDirection", + "in": "query", + "schema": { + "$ref": "#/components/schemas/DirectionModel" + } + }, + { + "name": "userGroupIds", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "userStates", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/UserStateModel" + } + } + }, + { + "name": "filter", + "in": "query", + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedUserResponseModel" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { @@ -35921,37 +35924,82 @@ } } }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, "403": { - "description": "The authenticated user does not have access to this resource", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/item/user": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetItemUser", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true + "oneOf": [ + { + "$ref": "#/components/schemas/UserItemResponseModel" + } + ] + } } } } + }, + "401": { + "description": "The resource is protected and requires an authentication token" } - } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] } }, - "/umbraco/management/api/v1/user/set-user-groups": { + "/umbraco/management/api/v1/user": { "post": { "tags": [ "User" ], - "operationId": "PostUserSetUserGroups", + "operationId": "PostUser", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateUserGroupsOnUserRequestModel" + "$ref": "#/components/schemas/CreateUserRequestModel" } ] } @@ -35960,7 +36008,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateUserGroupsOnUserRequestModel" + "$ref": "#/components/schemas/CreateUserRequestModel" } ] } @@ -35969,7 +36017,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateUserGroupsOnUserRequestModel" + "$ref": "#/components/schemas/CreateUserRequestModel" } ] } @@ -35977,8 +36025,64 @@ } }, "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -35990,6 +36094,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "401": { @@ -36016,21 +36131,19 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/user/unlock": { - "post": { + }, + "delete": { "tags": [ "User" ], - "operationId": "PostUserUnlock", + "operationId": "DeleteUser", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UnlockUsersRequestModel" + "$ref": "#/components/schemas/DeleteUsersRequestModel" } ] } @@ -36039,7 +36152,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UnlockUsersRequestModel" + "$ref": "#/components/schemas/DeleteUsersRequestModel" } ] } @@ -36048,7 +36161,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UnlockUsersRequestModel" + "$ref": "#/components/schemas/DeleteUsersRequestModel" } ] } @@ -36121,48 +36234,66 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/item/webhook": { + }, "get": { "tags": [ - "Webhook" + "User" ], - "operationId": "GetItemWebhook", + "operationId": "GetUser", "parameters": [ { - "name": "id", + "name": "skip", "in": "query", "schema": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } + "type": "integer", + "format": "int32", + "default": 0 } - } - ], - "responses": { - "200": { + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/WebhookItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/PagedUserResponseModel" + } + ] + } + } + } + }, + "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": [ @@ -36172,41 +36303,143 @@ ] } }, - "/umbraco/management/api/v1/webhook": { + "/umbraco/management/api/v1/user/{id}": { "get": { "tags": [ - "Webhook" + "User" ], - "operationId": "GetWebhook", + "operationId": "GetUserById", "parameters": [ { - "name": "skip", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 0 + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserResponseModel" + } + ] + } + } + } + }, + "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": [ { - "name": "take", - "in": "query", + "Backoffice-User": [ ] + } + ] + }, + "delete": { + "tags": [ + "User" + ], + "operationId": "DeleteUserById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, "schema": { - "type": "integer", - "format": "int32", - "default": 100 + "type": "string", + "format": "uuid" } } ], "responses": { "200": { "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedWebhookResponseModel" + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -36217,7 +36450,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -36226,18 +36471,29 @@ } ] }, - "post": { + "put": { "tags": [ - "Webhook" + "User" + ], + "operationId": "PutUserById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "operationId": "PostWebhook", "requestBody": { "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateWebhookRequestModel" + "$ref": "#/components/schemas/UpdateUserRequestModel" } ] } @@ -36246,7 +36502,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateWebhookRequestModel" + "$ref": "#/components/schemas/UpdateUserRequestModel" } ] } @@ -36255,7 +36511,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/CreateWebhookRequestModel" + "$ref": "#/components/schemas/UpdateUserRequestModel" } ] } @@ -36263,24 +36519,9 @@ } }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "headers": { - "Umb-Generated-Resource": { - "description": "Identifier of the newly created resource", - "schema": { - "type": "string", - "description": "Identifier of the newly created resource" - } - }, - "Location": { - "description": "Location of the newly created resource", - "schema": { - "type": "string", - "description": "Location of the newly created resource", - "format": "uri" - } - }, "Umb-Notifications": { "description": "The list of notifications produced during the request.", "schema": { @@ -36371,12 +36612,12 @@ ] } }, - "/umbraco/management/api/v1/webhook/{id}": { + "/umbraco/management/api/v1/user/{id}/2fa": { "get": { "tags": [ - "Webhook" + "User" ], - "operationId": "GetWebhookById", + "operationId": "GetUserById2fa", "parameters": [ { "name": "id", @@ -36394,11 +36635,14 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/WebhookResponseModel" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserTwoFactorProviderModel" + } + ] + } } } } @@ -36429,12 +36673,14 @@ "Backoffice-User": [ ] } ] - }, + } + }, + "/umbraco/management/api/v1/user/{id}/2fa/{providerName}": { "delete": { "tags": [ - "Webhook" + "User" ], - "operationId": "DeleteWebhookById", + "operationId": "DeleteUserById2faByProviderName", "parameters": [ { "name": "id", @@ -36444,11 +36690,19 @@ "type": "string", "format": "uuid" } + }, + { + "name": "providerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } } ], "responses": { - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -36460,17 +36714,6 @@ "nullable": true } } - }, - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemDetails" - } - ] - } - } } }, "404": { @@ -36499,8 +36742,8 @@ } } }, - "200": { - "description": "OK", + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -36512,6 +36755,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } } }, "401": { @@ -36538,12 +36792,74 @@ "Backoffice-User": [ ] } ] - }, - "put": { + } + }, + "/umbraco/management/api/v1/user/{id}/calculate-start-nodes": { + "get": { "tags": [ - "Webhook" + "User" ], - "operationId": "PutWebhookById", + "operationId": "GetUserByIdCalculateStartNodes", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CalculatedUserStartNodesResponseModel" + } + ] + } + } + } + }, + "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/user/{id}/change-password": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserByIdChangePassword", "parameters": [ { "name": "id", @@ -36561,7 +36877,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateWebhookRequestModel" + "$ref": "#/components/schemas/ChangePasswordUserRequestModel" } ] } @@ -36570,7 +36886,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateWebhookRequestModel" + "$ref": "#/components/schemas/ChangePasswordUserRequestModel" } ] } @@ -36579,7 +36895,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/UpdateWebhookRequestModel" + "$ref": "#/components/schemas/ChangePasswordUserRequestModel" } ] } @@ -36587,6 +36903,21 @@ } }, "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, "404": { "description": "Not Found", "headers": { @@ -36639,21 +36970,6 @@ } } }, - "200": { - "description": "OK", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -36680,12 +36996,12 @@ ] } }, - "/umbraco/management/api/v1/webhook/{id}/logs": { - "get": { + "/umbraco/management/api/v1/user/{id}/client-credentials": { + "post": { "tags": [ - "Webhook" + "User" ], - "operationId": "GetWebhookByIdLogs", + "operationId": "PostUserByIdClientCredentials", "parameters": [ { "name": "id", @@ -36695,35 +37011,75 @@ "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": 100 - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateUserClientCredentialsRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateUserClientCredentialsRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateUserClientCredentialsRequestModel" + } + ] + } + } + } + }, "responses": { "200": { "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, "content": { "application/json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/PagedWebhookLogResponseModel" + "$ref": "#/components/schemas/ProblemDetails" } ] } @@ -36734,7 +37090,19 @@ "description": "The resource is protected and requires an authentication token" }, "403": { - "description": "The authenticated user does not have access to this resource" + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -36742,20 +37110,3033 @@ "Backoffice-User": [ ] } ] - } - }, - "/umbraco/management/api/v1/webhook/events": { + }, "get": { "tags": [ - "Webhook" + "User" ], - "operationId": "GetWebhookEvents", + "operationId": "GetUserByIdClientCredentials", "parameters": [ { - "name": "skip", - "in": "query", - "schema": { - "type": "integer", + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "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/user/{id}/client-credentials/{clientId}": { + "delete": { + "tags": [ + "User" + ], + "operationId": "DeleteUserByIdClientCredentialsByClientId", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "clientId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/{id}/reset-password": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserByIdResetPassword", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordUserResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/avatar/{id}": { + "delete": { + "tags": [ + "User" + ], + "operationId": "DeleteUserAvatarById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + }, + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserAvatarById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SetAvatarRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SetAvatarRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SetAvatarRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/configuration": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserConfiguration", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserConfigurationResponseModel" + } + ] + } + } + } + }, + "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/user/current": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrent", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CurrentUserResponseModel" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/current/2fa": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrent2fa", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserTwoFactorProviderModel" + } + ] + } + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/current/2fa/{providerName}": { + "delete": { + "tags": [ + "User" + ], + "operationId": "DeleteUserCurrent2faByProviderName", + "parameters": [ + { + "name": "providerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + }, + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserCurrent2faByProviderName", + "parameters": [ + { + "name": "providerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EnableTwoFactorRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EnableTwoFactorRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EnableTwoFactorRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/NoopSetupTwoFactorModel" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + }, + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrent2faByProviderName", + "parameters": [ + { + "name": "providerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/NoopSetupTwoFactorModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/current/avatar": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserCurrentAvatar", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SetAvatarRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SetAvatarRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/SetAvatarRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/current/change-password": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserCurrentChangePassword", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/current/configuration": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrentConfiguration", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CurrentUserConfigurationResponseModel" + } + ] + } + } + } + }, + "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/user/current/login-providers": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrentLoginProviders", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserExternalLoginProviderModel" + } + ] + } + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/current/permissions": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrentPermissions", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserPermissionsResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/current/permissions/document": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrentPermissionsDocument", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserPermissionsResponseModel" + } + ] + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/current/permissions/element": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrentPermissionsElement", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserPermissionsResponseModel" + } + ] + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/current/permissions/media": { + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserCurrentPermissionsMedia", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserPermissionsResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/disable": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserDisable", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DisableUserRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DisableUserRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DisableUserRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/enable": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserEnable", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EnableUserRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EnableUserRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EnableUserRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/invite": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserInvite", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/InviteUserRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/InviteUserRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/InviteUserRequestModel" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/invite/create-password": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserInviteCreatePassword", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateInitialPasswordUserRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateInitialPasswordUserRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateInitialPasswordUserRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/user/invite/resend": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserInviteResend", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResendInviteUserRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResendInviteUserRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResendInviteUserRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/invite/verify": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserInviteVerify", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/VerifyInviteUserRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/VerifyInviteUserRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/VerifyInviteUserRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/VerifyInviteUserResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/user/set-user-groups": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserSetUserGroups", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateUserGroupsOnUserRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateUserGroupsOnUserRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateUserGroupsOnUserRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/unlock": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserUnlock", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnlockUsersRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnlockUsersRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnlockUsersRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/item/webhook": { + "get": { + "tags": [ + "Webhook" + ], + "operationId": "GetItemWebhook", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/WebhookItemResponseModel" + } + ] + } + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/webhook": { + "get": { + "tags": [ + "Webhook" + ], + "operationId": "GetWebhook", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedWebhookResponseModel" + } + ] + } + } + } + }, + "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": [ ] + } + ] + }, + "post": { + "tags": [ + "Webhook" + ], + "operationId": "PostWebhook", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateWebhookRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateWebhookRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateWebhookRequestModel" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "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", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/webhook/{id}": { + "get": { + "tags": [ + "Webhook" + ], + "operationId": "GetWebhookById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WebhookResponseModel" + } + ] + } + } + } + }, + "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": [ ] + } + ] + }, + "delete": { + "tags": [ + "Webhook" + ], + "operationId": "DeleteWebhookById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + }, + "put": { + "tags": [ + "Webhook" + ], + "operationId": "PutWebhookById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateWebhookRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateWebhookRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateWebhookRequestModel" + } + ] + } + } + } + }, + "responses": { + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/webhook/{id}/logs": { + "get": { + "tags": [ + "Webhook" + ], + "operationId": "GetWebhookByIdLogs", + "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": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedWebhookLogResponseModel" + } + ] + } + } + } + }, + "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/webhook/events": { + "get": { + "tags": [ + "Webhook" + ], + "operationId": "GetWebhookEvents", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", "format": "int32", "default": 0 } @@ -37068,7 +40449,9 @@ "CalculatedUserStartNodesResponseModel": { "required": [ "documentStartNodeIds", + "elementStartNodeIds", "hasDocumentRootAccess", + "hasElementRootAccess", "hasMediaRootAccess", "id", "mediaStartNodeIds" @@ -37106,6 +40489,20 @@ }, "hasMediaRootAccess": { "type": "boolean" + }, + "elementStartNodeIds": { + "uniqueItems": true, + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + }, + "hasElementRootAccess": { + "type": "boolean" } }, "additionalProperties": false @@ -37214,6 +40611,20 @@ }, "additionalProperties": false }, + "CopyElementRequestModel": { + "type": "object", + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + } + }, + "additionalProperties": false + }, "CopyMediaTypeRequestModel": { "type": "object", "properties": { @@ -37733,6 +41144,57 @@ }, "additionalProperties": false }, + "CreateElementRequestModel": { + "required": [ + "documentType", + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementVariantRequestModel" + } + ] + } + }, + "id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "parent": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + }, + "additionalProperties": false + }, "CreateFolderRequestModel": { "required": [ "name" @@ -38013,6 +41475,14 @@ "variesBySegment": { "type": "boolean" }, + "collection": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, "isElement": { "type": "boolean" }, @@ -38068,14 +41538,6 @@ } ] } - }, - "collection": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ], - "nullable": true } }, "additionalProperties": false @@ -38684,6 +42146,7 @@ "required": [ "alias", "documentRootAccess", + "elementRootAccess", "fallbackPermissions", "hasAccessToAllLanguages", "languages", @@ -38700,6 +42163,10 @@ "alias": { "type": "string" }, + "description": { + "type": "string", + "nullable": true + }, "icon": { "type": "string", "nullable": true @@ -38741,6 +42208,17 @@ "mediaRootAccess": { "type": "boolean" }, + "elementStartNode": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "elementRootAccess": { + "type": "boolean" + }, "fallbackPermissions": { "uniqueItems": true, "type": "array", @@ -38759,6 +42237,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/ElementPermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -38935,11 +42416,13 @@ "allowedSections", "avatarUrls", "documentStartNodeIds", + "elementStartNodeIds", "email", "fallbackPermissions", "hasAccessToAllLanguages", "hasAccessToSensitiveData", "hasDocumentRootAccess", + "hasElementRootAccess", "hasMediaRootAccess", "id", "isAdmin", @@ -39009,6 +42492,20 @@ "hasMediaRootAccess": { "type": "boolean" }, + "elementStartNodeIds": { + "uniqueItems": true, + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + }, + "hasElementRootAccess": { + "type": "boolean" + }, "avatarUrls": { "type": "array", "items": { @@ -39045,6 +42542,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/ElementPermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -39155,7 +42655,480 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/DataTypePropertyPresentationModel" + "$ref": "#/components/schemas/DataTypePropertyPresentationModel" + } + ] + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "isDeletable": { + "type": "boolean" + }, + "canIgnoreStartNodes": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DataTypeTreeItemResponseModel": { + "required": [ + "flags", + "hasChildren", + "id", + "isDeletable", + "isFolder", + "name", + "noAccess" + ], + "type": "object", + "properties": { + "hasChildren": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "parent": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "flags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/FlagModel" + } + ] + } + }, + "name": { + "type": "string" + }, + "isFolder": { + "type": "boolean" + }, + "noAccess": { + "type": "boolean" + }, + "editorUiAlias": { + "type": "string", + "nullable": true + }, + "isDeletable": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DatabaseInstallRequestModel": { + "required": [ + "id", + "providerName", + "trustServerCertificate", + "useIntegratedAuthentication" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "providerName": { + "minLength": 1, + "type": "string" + }, + "server": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "useIntegratedAuthentication": { + "type": "boolean" + }, + "connectionString": { + "type": "string", + "nullable": true + }, + "trustServerCertificate": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DatabaseSettingsPresentationModel": { + "required": [ + "defaultDatabaseName", + "displayName", + "id", + "isConfigured", + "providerName", + "requiresConnectionTest", + "requiresCredentials", + "requiresServer", + "serverPlaceholder", + "sortOrder", + "supportsIntegratedAuthentication", + "supportsTrustServerCertificate" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "displayName": { + "minLength": 1, + "type": "string" + }, + "defaultDatabaseName": { + "minLength": 1, + "type": "string" + }, + "providerName": { + "minLength": 1, + "type": "string" + }, + "isConfigured": { + "type": "boolean" + }, + "requiresServer": { + "type": "boolean" + }, + "serverPlaceholder": { + "minLength": 1, + "type": "string" + }, + "requiresCredentials": { + "type": "boolean" + }, + "supportsIntegratedAuthentication": { + "type": "boolean" + }, + "supportsTrustServerCertificate": { + "type": "boolean" + }, + "requiresConnectionTest": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DatatypeConfigurationResponseModel": { + "required": [ + "canBeChanged", + "documentListViewId", + "mediaListViewId" + ], + "type": "object", + "properties": { + "canBeChanged": { + "$ref": "#/components/schemas/DataTypeChangeModeModel" + }, + "documentListViewId": { + "type": "string", + "format": "uuid" + }, + "mediaListViewId": { + "type": "string", + "format": "uuid" + } + }, + "additionalProperties": false + }, + "DefaultReferenceResponseModel": { + "required": [ + "$type", + "id" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "nullable": true + }, + "icon": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DefaultReferenceResponseModel": "#/components/schemas/DefaultReferenceResponseModel" + } + } + }, + "DeleteUserGroupsRequestModel": { + "required": [ + "userGroupIds" + ], + "type": "object", + "properties": { + "userGroupIds": { + "uniqueItems": true, + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "DeleteUsersRequestModel": { + "required": [ + "userIds" + ], + "type": "object", + "properties": { + "userIds": { + "uniqueItems": true, + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "DictionaryItemItemResponseModel": { + "required": [ + "flags", + "id", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "flags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/FlagModel" + } + ] + } + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "DictionaryItemResponseModel": { + "required": [ + "id", + "name", + "translations" + ], + "type": "object", + "properties": { + "name": { + "minLength": 1, + "type": "string" + }, + "translations": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DictionaryItemTranslationModel" + } + ] + } + }, + "id": { + "type": "string", + "format": "uuid" + } + }, + "additionalProperties": false + }, + "DictionaryItemTranslationModel": { + "required": [ + "isoCode", + "translation" + ], + "type": "object", + "properties": { + "isoCode": { + "minLength": 1, + "type": "string" + }, + "translation": { + "type": "string" + } + }, + "additionalProperties": false + }, + "DictionaryOverviewResponseModel": { + "required": [ + "id", + "translatedIsoCodes" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "id": { + "type": "string", + "format": "uuid" + }, + "parent": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "translatedIsoCodes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "DirectionModel": { + "enum": [ + "Ascending", + "Descending" + ], + "type": "string" + }, + "DisableUserRequestModel": { + "required": [ + "userIds" + ], + "type": "object", + "properties": { + "userIds": { + "uniqueItems": true, + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "DocumentBlueprintItemResponseModel": { + "required": [ + "documentType", + "flags", + "id", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "flags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/FlagModel" + } + ] + } + }, + "name": { + "type": "string" + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + } + ] + } + }, + "additionalProperties": false + }, + "DocumentBlueprintResponseModel": { + "required": [ + "documentType", + "flags", + "id", + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentValueResponseModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantResponseModel" } ] } @@ -39164,23 +43137,34 @@ "type": "string", "format": "uuid" }, - "isDeletable": { - "type": "boolean" + "flags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/FlagModel" + } + ] + } }, - "canIgnoreStartNodes": { - "type": "boolean" + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + } + ] } }, "additionalProperties": false }, - "DataTypeTreeItemResponseModel": { + "DocumentBlueprintTreeItemResponseModel": { "required": [ "flags", "hasChildren", "id", - "isDeletable", "isFolder", - "name" + "name", + "noAccess" ], "type": "object", "properties": { @@ -39215,76 +43199,140 @@ "isFolder": { "type": "boolean" }, - "editorUiAlias": { - "type": "string", - "nullable": true - }, - "isDeletable": { + "noAccess": { "type": "boolean" + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + } + ], + "nullable": true } }, "additionalProperties": false }, - "DatabaseInstallRequestModel": { + "DocumentCollectionResponseModel": { "required": [ + "ancestors", + "documentType", + "flags", "id", - "providerName", - "trustServerCertificate", - "useIntegratedAuthentication" + "isProtected", + "isTrashed", + "sortOrder", + "values", + "variants" ], "type": "object", "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentValueResponseModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantResponseModel" + } + ] + } + }, "id": { "type": "string", "format": "uuid" }, - "providerName": { - "minLength": 1, - "type": "string" + "flags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/FlagModel" + } + ] + } }, - "server": { + "creator": { "type": "string", "nullable": true }, - "name": { - "type": "string", - "nullable": true + "sortOrder": { + "type": "integer", + "format": "int32" }, - "username": { - "type": "string", - "nullable": true + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeCollectionReferenceResponseModel" + } + ] }, - "password": { - "type": "string", - "nullable": true + "isTrashed": { + "type": "boolean" }, - "useIntegratedAuthentication": { + "isProtected": { "type": "boolean" }, - "connectionString": { + "ancestors": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + }, + "updater": { "type": "string", "nullable": true + } + }, + "additionalProperties": false + }, + "DocumentConfigurationResponseModel": { + "required": [ + "allowEditInvariantFromNonDefault", + "allowNonExistingSegmentsCreation", + "disableDeleteWhenReferenced", + "disableUnpublishWhenReferenced" + ], + "type": "object", + "properties": { + "disableDeleteWhenReferenced": { + "type": "boolean" }, - "trustServerCertificate": { + "disableUnpublishWhenReferenced": { + "type": "boolean" + }, + "allowEditInvariantFromNonDefault": { "type": "boolean" + }, + "allowNonExistingSegmentsCreation": { + "type": "boolean", + "deprecated": true } }, "additionalProperties": false }, - "DatabaseSettingsPresentationModel": { + "DocumentItemResponseModel": { "required": [ - "defaultDatabaseName", - "displayName", + "documentType", + "flags", + "hasChildren", "id", - "isConfigured", - "providerName", - "requiresConnectionTest", - "requiresCredentials", - "requiresServer", - "serverPlaceholder", - "sortOrder", - "supportsIntegratedAuthentication", - "supportsTrustServerCertificate" + "isProtected", + "isTrashed", + "variants" ], "type": "object", "properties": { @@ -39292,73 +43340,204 @@ "type": "string", "format": "uuid" }, - "sortOrder": { - "type": "integer", - "format": "int32" + "flags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/FlagModel" + } + ] + } }, - "displayName": { - "minLength": 1, - "type": "string" + "isTrashed": { + "type": "boolean" }, - "defaultDatabaseName": { - "minLength": 1, - "type": "string" + "isProtected": { + "type": "boolean" }, - "providerName": { - "minLength": 1, - "type": "string" + "parent": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true }, - "isConfigured": { + "hasChildren": { "type": "boolean" }, - "requiresServer": { - "type": "boolean" + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + } + ] }, - "serverPlaceholder": { - "minLength": 1, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "DocumentNotificationResponseModel": { + "required": [ + "actionId", + "alias", + "subscribed" + ], + "type": "object", + "properties": { + "actionId": { "type": "string" }, - "requiresCredentials": { - "type": "boolean" + "alias": { + "type": "string" }, - "supportsIntegratedAuthentication": { + "subscribed": { "type": "boolean" + } + }, + "additionalProperties": false + }, + "DocumentPermissionPresentationModel": { + "required": [ + "$type", + "document", + "verbs" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" }, - "supportsTrustServerCertificate": { - "type": "boolean" + "document": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] }, - "requiresConnectionTest": { - "type": "boolean" + "verbs": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentPermissionPresentationModel": "#/components/schemas/DocumentPermissionPresentationModel" + } + } }, - "DatatypeConfigurationResponseModel": { + "DocumentPropertyValuePermissionPresentationModel": { "required": [ - "canBeChanged", - "documentListViewId", - "mediaListViewId" + "$type", + "documentType", + "propertyType", + "verbs" ], "type": "object", "properties": { - "canBeChanged": { - "$ref": "#/components/schemas/DataTypeChangeModeModel" + "$type": { + "type": "string" }, - "documentListViewId": { + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + }, + "propertyType": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + }, + "verbs": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentPropertyValuePermissionPresentationModel": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" + } + } + }, + "DocumentRecycleBinItemResponseModel": { + "required": [ + "createDate", + "documentType", + "hasChildren", + "id", + "variants" + ], + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, - "mediaListViewId": { + "createDate": { "type": "string", - "format": "uuid" + "format": "date-time" + }, + "hasChildren": { + "type": "boolean" + }, + "parent": { + "oneOf": [ + { + "$ref": "#/components/schemas/ItemReferenceByIdResponseModel" + } + ], + "nullable": true + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + } + ] + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantItemResponseModel" + } + ] + } } }, "additionalProperties": false }, - "DefaultReferenceResponseModel": { + "DocumentReferenceResponseModel": { "required": [ "$type", - "id" + "documentType", + "id", + "variants" ], "type": "object", "properties": { @@ -39373,51 +43552,156 @@ "type": "string", "nullable": true }, - "type": { - "type": "string", + "published": { + "type": "boolean", "nullable": true }, - "icon": { - "type": "string", - "nullable": true + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrackedReferenceDocumentTypeModel" + } + ] + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantItemResponseModel" + } + ] + } } }, "additionalProperties": false, "discriminator": { "propertyName": "$type", "mapping": { - "DefaultReferenceResponseModel": "#/components/schemas/DefaultReferenceResponseModel" + "DocumentReferenceResponseModel": "#/components/schemas/DocumentReferenceResponseModel" } } }, - "DeleteUserGroupsRequestModel": { + "DocumentResponseModel": { "required": [ - "userGroupIds" + "documentType", + "flags", + "id", + "isTrashed", + "values", + "variants" ], "type": "object", "properties": { - "userGroupIds": { - "uniqueItems": true, + "values": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/ReferenceByIdModel" + "$ref": "#/components/schemas/DocumentValueResponseModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantResponseModel" + } + ] + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "flags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/FlagModel" } ] } + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + } + ] + }, + "template": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "isTrashed": { + "type": "boolean" } }, "additionalProperties": false }, - "DeleteUsersRequestModel": { + "DocumentTreeItemResponseModel": { "required": [ - "userIds" + "ancestors", + "createDate", + "documentType", + "flags", + "hasChildren", + "id", + "isProtected", + "isTrashed", + "noAccess", + "variants" ], "type": "object", "properties": { - "userIds": { - "uniqueItems": true, + "hasChildren": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "parent": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "flags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/FlagModel" + } + ] + } + }, + "noAccess": { + "type": "boolean" + }, + "isTrashed": { + "type": "boolean" + }, + "createDate": { + "type": "string", + "format": "date-time" + }, + "isProtected": { + "type": "boolean" + }, + "ancestors": { "type": "array", "items": { "oneOf": [ @@ -39426,11 +43710,28 @@ } ] } + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + } + ] + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantItemResponseModel" + } + ] + } } }, "additionalProperties": false }, - "DictionaryItemItemResponseModel": { + "DocumentTypeBlueprintItemResponseModel": { "required": [ "flags", "id", @@ -39458,116 +43759,163 @@ }, "additionalProperties": false }, - "DictionaryItemResponseModel": { + "DocumentTypeCleanupModel": { "required": [ - "id", - "name", - "translations" + "preventCleanup" ], "type": "object", "properties": { - "name": { - "minLength": 1, - "type": "string" + "preventCleanup": { + "type": "boolean" }, - "translations": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DictionaryItemTranslationModel" - } - ] - } + "keepAllVersionsNewerThanDays": { + "type": "integer", + "format": "int32", + "nullable": true }, - "id": { - "type": "string", - "format": "uuid" + "keepLatestVersionPerDayForDays": { + "type": "integer", + "format": "int32", + "nullable": true } }, "additionalProperties": false }, - "DictionaryItemTranslationModel": { + "DocumentTypeCollectionReferenceResponseModel": { "required": [ - "isoCode", - "translation" + "alias", + "icon", + "id" ], "type": "object", "properties": { - "isoCode": { - "minLength": 1, + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { "type": "string" }, - "translation": { + "icon": { "type": "string" + }, + "collection": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true } }, "additionalProperties": false }, - "DictionaryOverviewResponseModel": { + "DocumentTypeCompositionModel": { "required": [ - "id", - "translatedIsoCodes" + "compositionType", + "documentType" ], "type": "object", "properties": { - "name": { - "type": "string", - "nullable": true - }, - "id": { - "type": "string", - "format": "uuid" - }, - "parent": { + "documentType": { "oneOf": [ { "$ref": "#/components/schemas/ReferenceByIdModel" } - ], + ] + }, + "compositionType": { + "$ref": "#/components/schemas/CompositionTypeModel" + } + }, + "additionalProperties": false + }, + "DocumentTypeCompositionRequestModel": { + "required": [ + "currentCompositeIds", + "currentPropertyAliases", + "isElement" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", "nullable": true }, - "translatedIsoCodes": { + "currentPropertyAliases": { "type": "array", "items": { "type": "string" } + }, + "currentCompositeIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "isElement": { + "type": "boolean" } }, "additionalProperties": false }, - "DirectionModel": { - "enum": [ - "Ascending", - "Descending" + "DocumentTypeCompositionResponseModel": { + "required": [ + "icon", + "id", + "name" ], - "type": "string" + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "string" + } + }, + "additionalProperties": false }, - "DisableUserRequestModel": { + "DocumentTypeConfigurationResponseModel": { "required": [ - "userIds" + "dataTypesCanBeChanged", + "disableTemplates", + "reservedFieldNames", + "useSegments" ], "type": "object", "properties": { - "userIds": { + "dataTypesCanBeChanged": { + "$ref": "#/components/schemas/DataTypeChangeModeModel" + }, + "disableTemplates": { + "type": "boolean" + }, + "useSegments": { + "type": "boolean" + }, + "reservedFieldNames": { "uniqueItems": true, "type": "array", "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] + "type": "string" } } }, "additionalProperties": false }, - "DocumentBlueprintItemResponseModel": { + "DocumentTypeItemResponseModel": { "required": [ - "documentType", "flags", "id", + "isElement", "name" ], "type": "object", @@ -39589,88 +43937,113 @@ "name": { "type": "string" }, - "documentType": { + "isElement": { + "type": "boolean" + }, + "icon": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "DocumentTypePropertyTypeContainerResponseModel": { + "required": [ + "id", + "sortOrder", + "type" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "parent": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + "$ref": "#/components/schemas/ReferenceByIdModel" } - ] + ], + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "type": { + "minLength": 1, + "type": "string" + }, + "sortOrder": { + "type": "integer", + "format": "int32" } }, "additionalProperties": false }, - "DocumentBlueprintResponseModel": { + "DocumentTypePropertyTypeReferenceResponseModel": { "required": [ + "$type", "documentType", - "flags", - "id", - "values", - "variants" + "id" ], "type": "object", "properties": { - "values": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentValueResponseModel" - } - ] - } - }, - "variants": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentVariantResponseModel" - } - ] - } + "$type": { + "type": "string" }, "id": { "type": "string", "format": "uuid" }, - "flags": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/FlagModel" - } - ] - } + "name": { + "type": "string", + "nullable": true + }, + "alias": { + "type": "string", + "nullable": true }, "documentType": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + "$ref": "#/components/schemas/TrackedReferenceDocumentTypeModel" } ] } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTypePropertyTypeReferenceResponseModel": "#/components/schemas/DocumentTypePropertyTypeReferenceResponseModel" + } + } }, - "DocumentBlueprintTreeItemResponseModel": { + "DocumentTypePropertyTypeResponseModel": { "required": [ - "flags", - "hasChildren", + "alias", + "appearance", + "dataType", "id", - "isFolder", - "name" + "name", + "sortOrder", + "validation", + "variesByCulture", + "variesBySegment" ], "type": "object", "properties": { - "hasChildren": { - "type": "boolean" - }, "id": { "type": "string", "format": "uuid" }, - "parent": { + "container": { "oneOf": [ { "$ref": "#/components/schemas/ReferenceByIdModel" @@ -39678,26 +44051,70 @@ ], "nullable": true }, - "flags": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/FlagModel" - } - ] - } + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "alias": { + "minLength": 1, + "type": "string" }, "name": { + "minLength": 1, "type": "string" }, - "isFolder": { + "description": { + "type": "string", + "nullable": true + }, + "dataType": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + }, + "variesByCulture": { "type": "boolean" }, - "documentType": { + "variesBySegment": { + "type": "boolean" + }, + "validation": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + "$ref": "#/components/schemas/PropertyTypeValidationModel" + } + ] + }, + "appearance": { + "oneOf": [ + { + "$ref": "#/components/schemas/PropertyTypeAppearanceModel" + } + ] + } + }, + "additionalProperties": false + }, + "DocumentTypeReferenceResponseModel": { + "required": [ + "icon", + "id" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "icon": { + "type": "string" + }, + "collection": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" } ], "nullable": true @@ -39705,36 +44122,77 @@ }, "additionalProperties": false }, - "DocumentCollectionResponseModel": { + "DocumentTypeResponseModel": { "required": [ - "ancestors", - "documentType", - "flags", + "alias", + "allowedAsRoot", + "allowedDocumentTypes", + "allowedTemplates", + "cleanup", + "compositions", + "containers", + "icon", "id", - "isProtected", - "isTrashed", - "sortOrder", - "values", - "variants" + "isElement", + "name", + "properties", + "variesByCulture", + "variesBySegment" ], "type": "object", "properties": { - "values": { + "alias": { + "minLength": 1, + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "icon": { + "minLength": 1, + "type": "string" + }, + "allowedAsRoot": { + "type": "boolean" + }, + "variesByCulture": { + "type": "boolean" + }, + "variesBySegment": { + "type": "boolean" + }, + "collection": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "isElement": { + "type": "boolean" + }, + "properties": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentValueResponseModel" + "$ref": "#/components/schemas/DocumentTypePropertyTypeResponseModel" } ] } }, - "variants": { + "containers": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentVariantResponseModel" + "$ref": "#/components/schemas/DocumentTypePropertyTypeContainerResponseModel" } ] } @@ -39743,88 +44201,162 @@ "type": "string", "format": "uuid" }, - "flags": { + "allowedTemplates": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/FlagModel" + "$ref": "#/components/schemas/ReferenceByIdModel" } ] } }, - "creator": { - "type": "string", + "defaultTemplate": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], "nullable": true }, - "sortOrder": { - "type": "integer", - "format": "int32" + "cleanup": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeCleanupModel" + } + ] + }, + "allowedDocumentTypes": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeSortModel" + } + ] + } }, + "compositions": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeCompositionModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "DocumentTypeSortModel": { + "required": [ + "documentType", + "sortOrder" + ], + "type": "object", + "properties": { "documentType": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentTypeCollectionReferenceResponseModel" + "$ref": "#/components/schemas/ReferenceByIdModel" } ] }, - "isTrashed": { + "sortOrder": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "DocumentTypeTreeItemResponseModel": { + "required": [ + "flags", + "hasChildren", + "icon", + "id", + "isElement", + "isFolder", + "name", + "noAccess" + ], + "type": "object", + "properties": { + "hasChildren": { "type": "boolean" }, - "isProtected": { - "type": "boolean" + "id": { + "type": "string", + "format": "uuid" + }, + "parent": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true }, - "ancestors": { + "flags": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/ReferenceByIdModel" + "$ref": "#/components/schemas/FlagModel" } ] } }, - "updater": { - "type": "string", - "nullable": true + "name": { + "type": "string" + }, + "isFolder": { + "type": "boolean" + }, + "noAccess": { + "type": "boolean" + }, + "isElement": { + "type": "boolean" + }, + "icon": { + "type": "string" } }, "additionalProperties": false }, - "DocumentConfigurationResponseModel": { + "DocumentUrlInfoModel": { "required": [ - "allowEditInvariantFromNonDefault", - "allowNonExistingSegmentsCreation", - "disableDeleteWhenReferenced", - "disableUnpublishWhenReferenced" + "culture", + "message", + "provider", + "url" ], "type": "object", "properties": { - "disableDeleteWhenReferenced": { - "type": "boolean" + "culture": { + "type": "string", + "nullable": true }, - "disableUnpublishWhenReferenced": { - "type": "boolean" + "url": { + "type": "string", + "nullable": true }, - "allowEditInvariantFromNonDefault": { - "type": "boolean" + "message": { + "type": "string", + "nullable": true }, - "allowNonExistingSegmentsCreation": { - "type": "boolean", - "deprecated": true + "provider": { + "type": "string" } }, "additionalProperties": false }, - "DocumentItemResponseModel": { + "DocumentUrlInfoResponseModel": { "required": [ - "documentType", - "flags", - "hasChildren", "id", - "isProtected", - "isTrashed", - "variants" + "urlInfos" ], "type": "object", "properties": { @@ -39832,191 +44364,190 @@ "type": "string", "format": "uuid" }, - "flags": { + "urlInfos": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/FlagModel" + "$ref": "#/components/schemas/DocumentUrlInfoModel" } ] } - }, - "isTrashed": { - "type": "boolean" - }, - "isProtected": { - "type": "boolean" - }, - "parent": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ], + } + }, + "additionalProperties": false + }, + "DocumentValueModel": { + "required": [ + "alias" + ], + "type": "object", + "properties": { + "culture": { + "type": "string", "nullable": true }, - "hasChildren": { - "type": "boolean" + "segment": { + "type": "string", + "nullable": true }, - "documentType": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" - } - ] + "alias": { + "minLength": 1, + "type": "string" }, - "variants": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentVariantItemResponseModel" - } - ] - } + "value": { + "nullable": true } }, "additionalProperties": false }, - "DocumentNotificationResponseModel": { + "DocumentValueResponseModel": { "required": [ - "actionId", "alias", - "subscribed" + "editorAlias" ], "type": "object", "properties": { - "actionId": { - "type": "string" + "culture": { + "type": "string", + "nullable": true + }, + "segment": { + "type": "string", + "nullable": true }, "alias": { + "minLength": 1, "type": "string" }, - "subscribed": { - "type": "boolean" + "value": { + "nullable": true + }, + "editorAlias": { + "minLength": 1, + "type": "string" } }, "additionalProperties": false }, - "DocumentPermissionPresentationModel": { + "DocumentVariantItemResponseModel": { "required": [ - "$type", - "document", - "verbs" + "flags", + "id", + "name", + "state" ], "type": "object", "properties": { - "$type": { + "name": { "type": "string" }, - "document": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] + "culture": { + "type": "string", + "nullable": true }, - "verbs": { - "uniqueItems": true, + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "flags": { "type": "array", "items": { - "type": "string" + "oneOf": [ + { + "$ref": "#/components/schemas/FlagModel" + } + ] } + }, + "state": { + "$ref": "#/components/schemas/DocumentVariantStateModel" } }, - "additionalProperties": false, - "discriminator": { - "propertyName": "$type", - "mapping": { - "DocumentPermissionPresentationModel": "#/components/schemas/DocumentPermissionPresentationModel" - } - } + "additionalProperties": false }, - "DocumentPropertyValuePermissionPresentationModel": { + "DocumentVariantRequestModel": { "required": [ - "$type", - "documentType", - "propertyType", - "verbs" + "name" ], "type": "object", "properties": { - "$type": { - "type": "string" - }, - "documentType": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] + "culture": { + "type": "string", + "nullable": true }, - "propertyType": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] + "segment": { + "type": "string", + "nullable": true }, - "verbs": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string" - } + "name": { + "minLength": 1, + "type": "string" } }, - "additionalProperties": false, - "discriminator": { - "propertyName": "$type", - "mapping": { - "DocumentPropertyValuePermissionPresentationModel": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" - } - } + "additionalProperties": false }, - "DocumentRecycleBinItemResponseModel": { + "DocumentVariantResponseModel": { "required": [ "createDate", - "documentType", - "hasChildren", + "flags", "id", - "variants" + "name", + "state", + "updateDate" ], "type": "object", "properties": { - "id": { + "culture": { "type": "string", - "format": "uuid" + "nullable": true + }, + "segment": { + "type": "string", + "nullable": true + }, + "name": { + "minLength": 1, + "type": "string" }, "createDate": { "type": "string", "format": "date-time" }, - "hasChildren": { - "type": "boolean" + "updateDate": { + "type": "string", + "format": "date-time" }, - "parent": { - "oneOf": [ - { - "$ref": "#/components/schemas/ItemReferenceByIdResponseModel" - } - ], + "state": { + "$ref": "#/components/schemas/DocumentVariantStateModel" + }, + "publishDate": { + "type": "string", + "format": "date-time", "nullable": true }, - "documentType": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" - } - ] + "scheduledPublishDate": { + "type": "string", + "format": "date-time", + "nullable": true }, - "variants": { + "scheduledUnpublishDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "flags": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentVariantItemResponseModel" + "$ref": "#/components/schemas/FlagModel" } ] } @@ -40024,62 +44555,75 @@ }, "additionalProperties": false }, - "DocumentReferenceResponseModel": { + "DocumentVariantStateModel": { + "enum": [ + "NotCreated", + "Draft", + "Published", + "PublishedPendingChanges", + "Trashed" + ], + "type": "string" + }, + "DocumentVersionItemResponseModel": { "required": [ - "$type", + "document", "documentType", "id", - "variants" + "isCurrentDraftVersion", + "isCurrentPublishedVersion", + "preventCleanup", + "user", + "versionDate" ], "type": "object", "properties": { - "$type": { - "type": "string" - }, "id": { "type": "string", "format": "uuid" }, - "name": { - "type": "string", - "nullable": true - }, - "published": { - "type": "boolean", - "nullable": true + "document": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] }, "documentType": { "oneOf": [ { - "$ref": "#/components/schemas/TrackedReferenceDocumentTypeModel" + "$ref": "#/components/schemas/ReferenceByIdModel" } ] }, - "variants": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentVariantItemResponseModel" - } - ] - } + "user": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + }, + "versionDate": { + "type": "string", + "format": "date-time" + }, + "isCurrentPublishedVersion": { + "type": "boolean" + }, + "isCurrentDraftVersion": { + "type": "boolean" + }, + "preventCleanup": { + "type": "boolean" } }, - "additionalProperties": false, - "discriminator": { - "propertyName": "$type", - "mapping": { - "DocumentReferenceResponseModel": "#/components/schemas/DocumentReferenceResponseModel" - } - } + "additionalProperties": false }, - "DocumentResponseModel": { + "DocumentVersionResponseModel": { "required": [ "documentType", "flags", "id", - "isTrashed", "values", "variants" ], @@ -40126,289 +44670,221 @@ } ] }, - "template": { + "document": { "oneOf": [ { "$ref": "#/components/schemas/ReferenceByIdModel" } ], "nullable": true - }, - "isTrashed": { - "type": "boolean" } }, "additionalProperties": false }, - "DocumentTreeItemResponseModel": { + "DomainPresentationModel": { "required": [ - "ancestors", - "createDate", - "documentType", - "flags", - "hasChildren", - "id", - "isProtected", - "isTrashed", - "noAccess", - "variants" + "domainName", + "isoCode" ], "type": "object", "properties": { - "hasChildren": { - "type": "boolean" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "parent": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ], - "nullable": true - }, - "flags": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/FlagModel" - } - ] - } - }, - "noAccess": { - "type": "boolean" - }, - "isTrashed": { - "type": "boolean" - }, - "createDate": { - "type": "string", - "format": "date-time" - }, - "isProtected": { - "type": "boolean" - }, - "ancestors": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - } - }, - "documentType": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" - } - ] + "domainName": { + "type": "string" }, - "variants": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentVariantItemResponseModel" - } - ] - } + "isoCode": { + "type": "string" } }, "additionalProperties": false }, - "DocumentTypeBlueprintItemResponseModel": { + "DomainsResponseModel": { "required": [ - "flags", - "id", - "name" + "domains" ], "type": "object", "properties": { - "id": { + "defaultIsoCode": { "type": "string", - "format": "uuid" + "nullable": true }, - "flags": { + "domains": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/FlagModel" + "$ref": "#/components/schemas/DomainPresentationModel" } ] } - }, - "name": { - "type": "string" } }, "additionalProperties": false }, - "DocumentTypeCleanupModel": { + "DynamicRootContextRequestModel": { "required": [ - "preventCleanup" + "parent" ], "type": "object", "properties": { - "preventCleanup": { - "type": "boolean" + "id": { + "type": "string", + "format": "uuid", + "nullable": true }, - "keepAllVersionsNewerThanDays": { - "type": "integer", - "format": "int32", + "parent": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + }, + "culture": { + "type": "string", "nullable": true }, - "keepLatestVersionPerDayForDays": { - "type": "integer", - "format": "int32", + "segment": { + "type": "string", "nullable": true } }, "additionalProperties": false }, - "DocumentTypeCollectionReferenceResponseModel": { + "DynamicRootQueryOriginRequestModel": { "required": [ - "alias", - "icon", - "id" + "alias" ], "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, + "properties": { "alias": { "type": "string" }, - "icon": { - "type": "string" - }, - "collection": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ], + "id": { + "type": "string", + "format": "uuid", "nullable": true } }, "additionalProperties": false }, - "DocumentTypeCompositionModel": { + "DynamicRootQueryRequestModel": { "required": [ - "compositionType", - "documentType" + "origin", + "steps" ], "type": "object", "properties": { - "documentType": { + "origin": { "oneOf": [ { - "$ref": "#/components/schemas/ReferenceByIdModel" + "$ref": "#/components/schemas/DynamicRootQueryOriginRequestModel" } ] }, - "compositionType": { - "$ref": "#/components/schemas/CompositionTypeModel" + "steps": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DynamicRootQueryStepRequestModel" + } + ] + } } }, "additionalProperties": false }, - "DocumentTypeCompositionRequestModel": { + "DynamicRootQueryStepRequestModel": { "required": [ - "currentCompositeIds", - "currentPropertyAliases", - "isElement" + "alias", + "documentTypeIds" ], "type": "object", "properties": { - "id": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "currentPropertyAliases": { - "type": "array", - "items": { - "type": "string" - } + "alias": { + "type": "string" }, - "currentCompositeIds": { + "documentTypeIds": { "type": "array", "items": { "type": "string", "format": "uuid" } - }, - "isElement": { - "type": "boolean" } }, "additionalProperties": false }, - "DocumentTypeCompositionResponseModel": { + "DynamicRootRequestModel": { "required": [ - "icon", - "id", - "name" + "context", + "query" ], "type": "object", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" + "context": { + "oneOf": [ + { + "$ref": "#/components/schemas/DynamicRootContextRequestModel" + } + ] }, - "icon": { - "type": "string" + "query": { + "oneOf": [ + { + "$ref": "#/components/schemas/DynamicRootQueryRequestModel" + } + ] } }, "additionalProperties": false }, - "DocumentTypeConfigurationResponseModel": { + "DynamicRootResponseModel": { "required": [ - "dataTypesCanBeChanged", - "disableTemplates", - "reservedFieldNames", - "useSegments" + "roots" ], "type": "object", "properties": { - "dataTypesCanBeChanged": { - "$ref": "#/components/schemas/DataTypeChangeModeModel" + "roots": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "additionalProperties": false + }, + "ElementConfigurationResponseModel": { + "required": [ + "allowEditInvariantFromNonDefault", + "allowNonExistingSegmentsCreation", + "disableDeleteWhenReferenced", + "disableUnpublishWhenReferenced" + ], + "type": "object", + "properties": { + "disableDeleteWhenReferenced": { + "type": "boolean" }, - "disableTemplates": { + "disableUnpublishWhenReferenced": { "type": "boolean" }, - "useSegments": { + "allowEditInvariantFromNonDefault": { "type": "boolean" }, - "reservedFieldNames": { - "uniqueItems": true, - "type": "array", - "items": { - "type": "string" - } + "allowNonExistingSegmentsCreation": { + "type": "boolean", + "deprecated": true } }, "additionalProperties": false }, - "DocumentTypeItemResponseModel": { + "ElementItemResponseModel": { "required": [ + "documentType", "flags", + "hasChildren", "id", - "isElement", - "name" + "variants" ], "type": "object", "properties": { @@ -40426,35 +44902,6 @@ ] } }, - "name": { - "type": "string" - }, - "isElement": { - "type": "boolean" - }, - "icon": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "DocumentTypePropertyTypeContainerResponseModel": { - "required": [ - "id", - "sortOrder", - "type" - ], - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, "parent": { "oneOf": [ { @@ -40463,71 +44910,71 @@ ], "nullable": true }, - "name": { - "type": "string", - "nullable": true + "hasChildren": { + "type": "boolean" }, - "type": { - "minLength": 1, - "type": "string" + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + } + ] }, - "sortOrder": { - "type": "integer", - "format": "int32" + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementVariantItemResponseModel" + } + ] + } } }, "additionalProperties": false }, - "DocumentTypePropertyTypeReferenceResponseModel": { + "ElementPermissionPresentationModel": { "required": [ "$type", - "documentType", - "id" + "element", + "verbs" ], "type": "object", "properties": { "$type": { "type": "string" }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string", - "nullable": true - }, - "alias": { - "type": "string", - "nullable": true - }, - "documentType": { + "element": { "oneOf": [ { - "$ref": "#/components/schemas/TrackedReferenceDocumentTypeModel" + "$ref": "#/components/schemas/ReferenceByIdModel" } ] + }, + "verbs": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false, "discriminator": { "propertyName": "$type", "mapping": { - "DocumentTypePropertyTypeReferenceResponseModel": "#/components/schemas/DocumentTypePropertyTypeReferenceResponseModel" + "ElementPermissionPresentationModel": "#/components/schemas/ElementPermissionPresentationModel" } } }, - "DocumentTypePropertyTypeResponseModel": { + "ElementRecycleBinItemResponseModel": { "required": [ - "alias", - "appearance", - "dataType", + "createDate", + "hasChildren", "id", + "isFolder", "name", - "sortOrder", - "validation", - "variesByCulture", - "variesBySegment" + "variants" ], "type": "object", "properties": { @@ -40535,156 +44982,75 @@ "type": "string", "format": "uuid" }, - "container": { + "createDate": { + "type": "string", + "format": "date-time" + }, + "hasChildren": { + "type": "boolean" + }, + "parent": { "oneOf": [ { - "$ref": "#/components/schemas/ReferenceByIdModel" + "$ref": "#/components/schemas/ItemReferenceByIdResponseModel" } ], "nullable": true }, - "sortOrder": { - "type": "integer", - "format": "int32" - }, - "alias": { - "minLength": 1, - "type": "string" - }, - "name": { - "minLength": 1, - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "dataType": { + "documentType": { "oneOf": [ { - "$ref": "#/components/schemas/ReferenceByIdModel" + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" } - ] + ], + "nullable": true }, - "variesByCulture": { - "type": "boolean" + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementVariantItemResponseModel" + } + ] + } }, - "variesBySegment": { + "isFolder": { "type": "boolean" }, - "validation": { - "oneOf": [ - { - "$ref": "#/components/schemas/PropertyTypeValidationModel" - } - ] - }, - "appearance": { - "oneOf": [ - { - "$ref": "#/components/schemas/PropertyTypeAppearanceModel" - } - ] - } - }, - "additionalProperties": false - }, - "DocumentTypeReferenceResponseModel": { - "required": [ - "icon", - "id" - ], - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "icon": { + "name": { "type": "string" - }, - "collection": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ], - "nullable": true } }, "additionalProperties": false }, - "DocumentTypeResponseModel": { + "ElementResponseModel": { "required": [ - "alias", - "allowedAsRoot", - "allowedDocumentTypes", - "allowedTemplates", - "cleanup", - "compositions", - "containers", - "icon", + "documentType", + "flags", "id", - "isElement", - "name", - "properties", - "variesByCulture", - "variesBySegment" + "isTrashed", + "values", + "variants" ], "type": "object", "properties": { - "alias": { - "minLength": 1, - "type": "string" - }, - "name": { - "minLength": 1, - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "icon": { - "minLength": 1, - "type": "string" - }, - "allowedAsRoot": { - "type": "boolean" - }, - "variesByCulture": { - "type": "boolean" - }, - "variesBySegment": { - "type": "boolean" - }, - "collection": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ], - "nullable": true - }, - "isElement": { - "type": "boolean" - }, - "properties": { + "values": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentTypePropertyTypeResponseModel" + "$ref": "#/components/schemas/ElementValueResponseModel" } ] } }, - "containers": { + "variants": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentTypePropertyTypeContainerResponseModel" + "$ref": "#/components/schemas/ElementVariantResponseModel" } ] } @@ -40693,84 +45059,39 @@ "type": "string", "format": "uuid" }, - "allowedTemplates": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - } - }, - "defaultTemplate": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ], - "nullable": true - }, - "cleanup": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTypeCleanupModel" - } - ] - }, - "allowedDocumentTypes": { + "flags": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentTypeSortModel" + "$ref": "#/components/schemas/FlagModel" } ] } }, - "compositions": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTypeCompositionModel" - } - ] - } - } - }, - "additionalProperties": false - }, - "DocumentTypeSortModel": { - "required": [ - "documentType", - "sortOrder" - ], - "type": "object", - "properties": { "documentType": { "oneOf": [ { - "$ref": "#/components/schemas/ReferenceByIdModel" + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" } ] }, - "sortOrder": { - "type": "integer", - "format": "int32" + "isTrashed": { + "type": "boolean" } }, "additionalProperties": false }, - "DocumentTypeTreeItemResponseModel": { + "ElementTreeItemResponseModel": { "required": [ + "createDate", "flags", "hasChildren", - "icon", "id", - "isElement", "isFolder", - "name" + "name", + "noAccess", + "variants" ], "type": "object", "properties": { @@ -40805,59 +45126,27 @@ "isFolder": { "type": "boolean" }, - "isElement": { + "noAccess": { "type": "boolean" }, - "icon": { - "type": "string" - } - }, - "additionalProperties": false - }, - "DocumentUrlInfoModel": { - "required": [ - "culture", - "message", - "provider", - "url" - ], - "type": "object", - "properties": { - "culture": { - "type": "string", - "nullable": true - }, - "url": { + "createDate": { "type": "string", - "nullable": true + "format": "date-time" }, - "message": { - "type": "string", + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + } + ], "nullable": true }, - "provider": { - "type": "string" - } - }, - "additionalProperties": false - }, - "DocumentUrlInfoResponseModel": { - "required": [ - "id", - "urlInfos" - ], - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "urlInfos": { + "variants": { "type": "array", "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentUrlInfoModel" + "$ref": "#/components/schemas/ElementVariantItemResponseModel" } ] } @@ -40865,7 +45154,7 @@ }, "additionalProperties": false }, - "DocumentValueModel": { + "ElementValueModel": { "required": [ "alias" ], @@ -40889,7 +45178,7 @@ }, "additionalProperties": false }, - "DocumentValueResponseModel": { + "ElementValueResponseModel": { "required": [ "alias", "editorAlias" @@ -40918,10 +45207,8 @@ }, "additionalProperties": false }, - "DocumentVariantItemResponseModel": { + "ElementVariantItemResponseModel": { "required": [ - "flags", - "id", "name", "state" ], @@ -40934,28 +45221,13 @@ "type": "string", "nullable": true }, - "id": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "flags": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/FlagModel" - } - ] - } - }, "state": { "$ref": "#/components/schemas/DocumentVariantStateModel" } }, "additionalProperties": false }, - "DocumentVariantRequestModel": { + "ElementVariantRequestModel": { "required": [ "name" ], @@ -40976,11 +45248,9 @@ }, "additionalProperties": false }, - "DocumentVariantResponseModel": { + "ElementVariantResponseModel": { "required": [ "createDate", - "flags", - "id", "name", "state", "updateDate" @@ -41024,39 +45294,14 @@ "type": "string", "format": "date-time", "nullable": true - }, - "id": { - "type": "string", - "format": "uuid", - "readOnly": true - }, - "flags": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/FlagModel" - } - ] - } } }, "additionalProperties": false }, - "DocumentVariantStateModel": { - "enum": [ - "NotCreated", - "Draft", - "Published", - "PublishedPendingChanges", - "Trashed" - ], - "type": "string" - }, - "DocumentVersionItemResponseModel": { + "ElementVersionItemResponseModel": { "required": [ - "document", "documentType", + "element", "id", "isCurrentDraftVersion", "isCurrentPublishedVersion", @@ -41070,7 +45315,7 @@ "type": "string", "format": "uuid" }, - "document": { + "element": { "oneOf": [ { "$ref": "#/components/schemas/ReferenceByIdModel" @@ -41107,7 +45352,7 @@ }, "additionalProperties": false }, - "DocumentVersionResponseModel": { + "ElementVersionResponseModel": { "required": [ "documentType", "flags", @@ -41122,7 +45367,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentValueResponseModel" + "$ref": "#/components/schemas/ElementValueResponseModel" } ] } @@ -41132,7 +45377,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentVariantResponseModel" + "$ref": "#/components/schemas/ElementVariantResponseModel" } ] } @@ -41158,7 +45403,7 @@ } ] }, - "document": { + "element": { "oneOf": [ { "$ref": "#/components/schemas/ReferenceByIdModel" @@ -41169,178 +45414,6 @@ }, "additionalProperties": false }, - "DomainPresentationModel": { - "required": [ - "domainName", - "isoCode" - ], - "type": "object", - "properties": { - "domainName": { - "type": "string" - }, - "isoCode": { - "type": "string" - } - }, - "additionalProperties": false - }, - "DomainsResponseModel": { - "required": [ - "domains" - ], - "type": "object", - "properties": { - "defaultIsoCode": { - "type": "string", - "nullable": true - }, - "domains": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DomainPresentationModel" - } - ] - } - } - }, - "additionalProperties": false - }, - "DynamicRootContextRequestModel": { - "required": [ - "parent" - ], - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "parent": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - }, - "culture": { - "type": "string", - "nullable": true - }, - "segment": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "DynamicRootQueryOriginRequestModel": { - "required": [ - "alias" - ], - "type": "object", - "properties": { - "alias": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid", - "nullable": true - } - }, - "additionalProperties": false - }, - "DynamicRootQueryRequestModel": { - "required": [ - "origin", - "steps" - ], - "type": "object", - "properties": { - "origin": { - "oneOf": [ - { - "$ref": "#/components/schemas/DynamicRootQueryOriginRequestModel" - } - ] - }, - "steps": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DynamicRootQueryStepRequestModel" - } - ] - } - } - }, - "additionalProperties": false - }, - "DynamicRootQueryStepRequestModel": { - "required": [ - "alias", - "documentTypeIds" - ], - "type": "object", - "properties": { - "alias": { - "type": "string" - }, - "documentTypeIds": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - }, - "additionalProperties": false - }, - "DynamicRootRequestModel": { - "required": [ - "context", - "query" - ], - "type": "object", - "properties": { - "context": { - "oneOf": [ - { - "$ref": "#/components/schemas/DynamicRootContextRequestModel" - } - ] - }, - "query": { - "oneOf": [ - { - "$ref": "#/components/schemas/DynamicRootQueryRequestModel" - } - ] - } - }, - "additionalProperties": false - }, - "DynamicRootResponseModel": { - "required": [ - "roots" - ], - "type": "object", - "properties": { - "roots": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - } - } - }, - "additionalProperties": false - }, "EnableTwoFactorRequestModel": { "required": [ "code", @@ -41483,9 +45556,38 @@ }, "additionalProperties": false }, + "FolderItemResponseModel": { + "required": [ + "flags", + "id", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "flags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/FlagModel" + } + ] + } + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, "FolderResponseModel": { "required": [ "id", + "isTrashed", "name" ], "type": "object", @@ -41497,6 +45599,9 @@ "id": { "type": "string", "format": "uuid" + }, + "isTrashed": { + "type": "boolean" } }, "additionalProperties": false @@ -42992,7 +47097,8 @@ "id", "isDeletable", "isFolder", - "name" + "name", + "noAccess" ], "type": "object", "properties": { @@ -43027,6 +47133,9 @@ "isFolder": { "type": "boolean" }, + "noAccess": { + "type": "boolean" + }, "icon": { "type": "string" }, @@ -43839,7 +47948,8 @@ "icon", "id", "isFolder", - "name" + "name", + "noAccess" ], "type": "object", "properties": { @@ -43874,6 +47984,9 @@ "isFolder": { "type": "boolean" }, + "noAccess": { + "type": "boolean" + }, "icon": { "type": "string" } @@ -44091,6 +48204,34 @@ }, "additionalProperties": false }, + "MoveElementRequestModel": { + "type": "object", + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + } + }, + "additionalProperties": false + }, + "MoveFolderRequestModel": { + "type": "object", + "properties": { + "target": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + } + }, + "additionalProperties": false + }, "MoveMediaRequestModel": { "type": "object", "properties": { @@ -44783,6 +48924,78 @@ }, "additionalProperties": false }, + "PagedElementRecycleBinItemResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementRecycleBinItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "PagedElementTreeItemResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "PagedElementVersionItemResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementVersionItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "PagedFileSystemTreeItemPresentationModel": { "required": [ "items", @@ -46263,6 +50476,25 @@ }, "additionalProperties": false }, + "PublishElementRequestModel": { + "required": [ + "publishSchedules" + ], + "type": "object", + "properties": { + "publishSchedules": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/CultureAndScheduleRequestModel" + } + ] + } + } + }, + "additionalProperties": false + }, "PublishWithDescendantsResultModel": { "required": [ "isComplete", @@ -46936,6 +51168,7 @@ "required": [ "allowLocalLogin", "allowPasswordReset", + "umbracoCssPath", "versionCheckPeriod" ], "type": "object", @@ -46949,6 +51182,9 @@ }, "allowLocalLogin": { "type": "boolean" + }, + "umbracoCssPath": { + "type": "string" } }, "additionalProperties": false @@ -47313,6 +51549,64 @@ }, "additionalProperties": false }, + "SubsetElementRecycleBinItemResponseModel": { + "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/ElementRecycleBinItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetElementTreeItemResponseModel": { + "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/ElementTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "SubsetFileSystemTreeItemPresentationModel": { "required": [ "items", @@ -48031,6 +52325,20 @@ }, "additionalProperties": false }, + "UnpublishElementRequestModel": { + "type": "object", + "properties": { + "cultures": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "UpdateDataTypeRequestModel": { "required": [ "editorAlias", @@ -48423,6 +52731,36 @@ }, "additionalProperties": false }, + "UpdateElementRequestModel": { + "required": [ + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementVariantRequestModel" + } + ] + } + } + }, + "additionalProperties": false + }, "UpdateFolderResponseModel": { "required": [ "name" @@ -49161,6 +53499,7 @@ "required": [ "alias", "documentRootAccess", + "elementRootAccess", "fallbackPermissions", "hasAccessToAllLanguages", "languages", @@ -49177,6 +53516,10 @@ "alias": { "type": "string" }, + "description": { + "type": "string", + "nullable": true + }, "icon": { "type": "string", "nullable": true @@ -49218,6 +53561,17 @@ "mediaRootAccess": { "type": "boolean" }, + "elementStartNode": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "elementRootAccess": { + "type": "boolean" + }, "fallbackPermissions": { "uniqueItems": true, "type": "array", @@ -49236,6 +53590,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/ElementPermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -49280,8 +53637,10 @@ "UpdateUserRequestModel": { "required": [ "documentStartNodeIds", + "elementStartNodeIds", "email", "hasDocumentRootAccess", + "hasElementRootAccess", "hasMediaRootAccess", "languageIsoCode", "mediaStartNodeIds", @@ -49341,6 +53700,20 @@ }, "hasMediaRootAccess": { "type": "boolean" + }, + "elementStartNodeIds": { + "uniqueItems": true, + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + }, + "hasElementRootAccess": { + "type": "boolean" } }, "additionalProperties": false @@ -49596,6 +53969,7 @@ "alias", "aliasCanBeChanged", "documentRootAccess", + "elementRootAccess", "fallbackPermissions", "hasAccessToAllLanguages", "id", @@ -49614,6 +53988,10 @@ "alias": { "type": "string" }, + "description": { + "type": "string", + "nullable": true + }, "icon": { "type": "string", "nullable": true @@ -49655,6 +54033,17 @@ "mediaRootAccess": { "type": "boolean" }, + "elementStartNode": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "elementRootAccess": { + "type": "boolean" + }, "fallbackPermissions": { "uniqueItems": true, "type": "array", @@ -49673,6 +54062,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/ElementPermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -49827,9 +54219,11 @@ "avatarUrls", "createDate", "documentStartNodeIds", + "elementStartNodeIds", "email", "failedLoginAttempts", "hasDocumentRootAccess", + "hasElementRootAccess", "hasMediaRootAccess", "id", "isAdmin", @@ -49899,6 +54293,20 @@ "hasMediaRootAccess": { "type": "boolean" }, + "elementStartNodeIds": { + "uniqueItems": true, + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + } + }, + "hasElementRootAccess": { + "type": "boolean" + }, "avatarUrls": { "type": "array", "items": { @@ -50046,6 +54454,44 @@ }, "additionalProperties": false }, + "ValidateUpdateElementRequestModel": { + "required": [ + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ElementVariantRequestModel" + } + ] + } + }, + "cultures": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "VariantItemResponseModel": { "required": [ "name" @@ -50379,6 +54825,12 @@ { "name": "Dynamic Root" }, + { + "name": "Element Version" + }, + { + "name": "Element" + }, { "name": "Health Check" }, diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Element/ElementPermissionHandler.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Element/ElementPermissionHandler.cs new file mode 100644 index 000000000000..288a2eafc13b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Element/ElementPermissionHandler.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Security.Authorization.Element; + +/// +/// Authorizes that the current user has the correct permission access to the element item(s) specified in the request. +/// +public class ElementPermissionHandler : MustSatisfyRequirementAuthorizationHandler +{ + private readonly IElementPermissionAuthorizer _elementPermissionAuthorizer; + private readonly IAuthorizationHelper _authorizationHelper; + + /// + /// Initializes a new instance of the class. + /// + /// Authorizer for element access. + /// The authorization helper. + public ElementPermissionHandler(IElementPermissionAuthorizer elementPermissionAuthorizer, IAuthorizationHelper authorizationHelper) + { + _elementPermissionAuthorizer = elementPermissionAuthorizer; + _authorizationHelper = authorizationHelper; + } + + /// + protected override async Task IsAuthorized( + AuthorizationHandlerContext context, + ElementPermissionRequirement requirement, + ElementPermissionResource resource) + { + var result = true; + + IUser user = _authorizationHelper.GetUmbracoUser(context.User); + if (resource.CheckRoot) + { + result &= await _elementPermissionAuthorizer.IsDeniedAtRootLevelAsync(user, resource.PermissionsToCheck) is false; + } + + if (resource.CheckRecycleBin) + { + result &= await _elementPermissionAuthorizer.IsDeniedAtRecycleBinLevelAsync(user, resource.PermissionsToCheck) is false; + } + + if (resource.ParentKeyForBranch is not null) + { + result &= await _elementPermissionAuthorizer.IsDeniedWithDescendantsAsync(user, resource.ParentKeyForBranch.Value, resource.PermissionsToCheck) is false; + } + + if (resource.ElementKeys.Any()) + { + result &= await _elementPermissionAuthorizer.IsDeniedAsync(user, resource.ElementKeys, resource.PermissionsToCheck) is false; + } + + if (resource.CulturesToCheck is not null) + { + result &= await _elementPermissionAuthorizer.IsDeniedForCultures(user, resource.CulturesToCheck) is false; + } + + return result; + } +} \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Element/ElementPermissionRequirement.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Element/ElementPermissionRequirement.cs new file mode 100644 index 000000000000..c67f8080737c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Element/ElementPermissionRequirement.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Umbraco.Cms.Api.Management.Security.Authorization.Element; + +/// +/// Authorization requirement for the . +/// +public class ElementPermissionRequirement : IAuthorizationRequirement +{ +} \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/RelationEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/RelationEventAuthorizer.cs index c7f575582163..50b90ebc13db 100644 --- a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/RelationEventAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/RelationEventAuthorizer.cs @@ -12,5 +12,5 @@ public RelationEventAuthorizer(IAuthorizationService authorizationService) : bas public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Relation]; - protected override string Policy => AuthorizationPolicies.TreeAccessDocumentsOrMediaOrMembersOrContentTypes; + protected override string Policy => AuthorizationPolicies.TreeAccessDocumentsOrElementsOrMediaOrMembersOrContentTypes; } diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs index acd3bede71f1..7898dd4e0f2c 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs @@ -23,6 +23,25 @@ public interface IUserStartNodeEntitiesService /// IEnumerable RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds); + /// + /// Calculates the applicable root entities for multiple object types for users without root access. + /// + /// The object types to query. + /// The calculated start node IDs for the user. + /// A list of root entities for the user across all specified object types. + /// + /// 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[] umbracoObjectTypes, + int[] userStartNodeIds) + => throw new NotImplementedException(); + /// /// Calculates the applicable child entities for a given object type for users without root access. /// @@ -51,6 +70,31 @@ IEnumerable ChildUserAccessEntities( return []; } + /// + /// Calculates the applicable child entities for multiple object types for users without root access. + /// + /// The object types to query. + /// 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 across all object types. + /// A list of child entities applicable for the user across all specified object types. + /// + /// 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[] umbracoObjectTypes, + string[] userStartNodePaths, + Guid parentKey, + int skip, + int take, + Ordering ordering, + out long totalItems) + => throw new NotImplementedException(); + /// /// Calculates the applicable child entities from a list of candidate child entities for users without root access. /// @@ -95,6 +139,33 @@ IEnumerable SiblingUserAccessEntities( return []; } + /// + /// Calculates the applicable sibling entities for multiple object types for users without root access. + /// + /// The object types to query. + /// The calculated start node paths for the user. + /// The key of the target. + /// 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 siblings. + /// Outputs the total number of siblings before the target entity across all object types. + /// Outputs the total number of siblings after the target entity across all object types. + /// A list of sibling entities applicable for the user across all specified object types. + /// + /// 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 SiblingUserAccessEntities( + UmbracoObjectTypes[] umbracoObjectTypes, + string[] userStartNodePaths, + Guid targetKey, + int before, + int after, + Ordering ordering, + out long totalBefore, + out long totalAfter) + => throw new NotImplementedException(); + /// /// 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 94047b46c8e0..b56476b97f30 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs @@ -1,12 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.Models.Entities; 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.Cms.Core.Services; using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Services.Entities; @@ -18,30 +16,45 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService { private readonly IEntityService _entityService; private readonly ICoreScopeProvider _scopeProvider; - private readonly IIdKeyMap _idKeyMap; /// /// Initializes a new instance of the class. /// /// The entity service. /// The core scope provider. - /// The ID to key mapping service. - public UserStartNodeEntitiesService(IEntityService entityService, ICoreScopeProvider scopeProvider, IIdKeyMap idKeyMap) + public UserStartNodeEntitiesService(IEntityService entityService, ICoreScopeProvider scopeProvider) { _entityService = entityService; _scopeProvider = scopeProvider; - _idKeyMap = idKeyMap; + } + + /// + /// Initializes a new instance of the class. + /// + /// The entity service. + /// The core scope provider. + /// The ID to key mapping service. + [Obsolete("Use the constructor without IIdKeyMap. Scheduled for removal in Umbraco 19.")] + public UserStartNodeEntitiesService(IEntityService entityService, ICoreScopeProvider scopeProvider, IIdKeyMap idKeyMap) + : this(entityService, scopeProvider) + { } /// public IEnumerable RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds) + => RootUserAccessEntities([umbracoObjectType], userStartNodeIds); + + /// + public IEnumerable RootUserAccessEntities(UmbracoObjectTypes[] umbracoObjectTypes, int[] userStartNodeIds) { // Root entities for users without root access should include: // - the start nodes that are actual root entities (level == 1) // - the root level ancestors to the rest of the start nodes (required for browsing to the actual start nodes - will be marked as "no access") + + // Collect start entities from all object types IEntitySlim[] userStartEntities = userStartNodeIds.Any() - ? _entityService.GetAll(umbracoObjectType, userStartNodeIds).ToArray() - : Array.Empty(); + ? _entityService.GetAll(umbracoObjectTypes, userStartNodeIds).ToArray() + : []; // Find the start nodes that are at root level (level == 1). IEntitySlim[] allowedTopmostEntities = userStartEntities.Where(entity => entity.Level == 1).ToArray(); @@ -51,9 +64,11 @@ public IEnumerable RootUserAccessEntities(UmbracoObjectTypes u .Select(entity => int.TryParse(entity.Path.Split(Constants.CharArrays.Comma).Skip(1).FirstOrDefault(), out var id) ? id : 0) .Where(id => id > 0) .ToArray(); + + // Get non-allowed topmost entities from all object types IEntitySlim[] nonAllowedTopmostEntities = nonAllowedTopmostEntityIds.Any() - ? _entityService.GetAll(umbracoObjectType, nonAllowedTopmostEntityIds).ToArray() - : Array.Empty(); + ? _entityService.GetAll(umbracoObjectTypes, nonAllowedTopmostEntityIds).ToArray() + : []; return allowedTopmostEntities .Select(entity => new UserAccessEntity(entity, true)) @@ -72,45 +87,66 @@ public IEnumerable ChildUserAccessEntities( int take, Ordering ordering, out long totalItems) - { - Attempt parentIdAttempt = _idKeyMap.GetIdForKey(parentKey, umbracoObjectType); - if (parentIdAttempt.Success is false) - { - totalItems = 0; - return []; - } + => ChildUserAccessEntities([umbracoObjectType], userStartNodePaths, parentKey, skip, take, ordering, out totalItems); - var parentId = parentIdAttempt.Result; - IEntitySlim? parent = _entityService.Get(parentId); + /// + public IEnumerable ChildUserAccessEntities( + UmbracoObjectTypes[] umbracoObjectTypes, + string[] userStartNodePaths, + Guid parentKey, + int skip, + int take, + Ordering ordering, + out long totalItems) + { + IEntitySlim? parent = _entityService.Get(parentKey); 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); + IEnumerable allChildren = _entityService.GetPagedChildren( + parentKey, + umbracoObjectTypes, + umbracoObjectTypes, + skip, + take, + false, + out totalItems, + ordering: ordering); + + return ChildUserAccessEntities(allChildren, userStartNodePaths); } // Need to use a List here because the expression tree cannot convert an array when used in Contains. // See ExpressionTests.Sql_In(). - List allowedChildIds = GetAllowedIds(userStartNodePaths, parentId); + List allowedChildIds = GetAllowedIds(userStartNodePaths, parent.Id); - totalItems = allowedChildIds.Count; if (allowedChildIds.Count == 0) { // The requested parent is outside the scope of any user start nodes. + totalItems = 0; return []; } - // Even though we know the IDs of the allowed child entities to fetch, we still use a Query to yield correctly sorted children. + // Fetch allowed children from all object types 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); + IEnumerable allAllowedChildren = _entityService.GetPagedChildren( + parentKey, + umbracoObjectTypes, + umbracoObjectTypes, + skip, + take, + false, + out totalItems, + query, + ordering); + + return ChildUserAccessEntities(allAllowedChildren, userStartNodePaths); } private static List GetAllowedIds(string[] userStartNodePaths, int parentId) @@ -161,17 +197,20 @@ public IEnumerable SiblingUserAccessEntities( Ordering ordering, out long totalBefore, out long totalAfter) - { - Attempt targetIdAttempt = _idKeyMap.GetIdForKey(targetKey, umbracoObjectType); - if (targetIdAttempt.Success is false) - { - totalBefore = 0; - totalAfter = 0; - return []; - } + => SiblingUserAccessEntities([umbracoObjectType], userStartNodePaths, targetKey, before, after, ordering, out totalBefore, out totalAfter); - var targetId = targetIdAttempt.Result; - IEntitySlim? target = _entityService.Get(targetId); + /// + public IEnumerable SiblingUserAccessEntities( + UmbracoObjectTypes[] umbracoObjectTypes, + string[] userStartNodePaths, + Guid targetKey, + int before, + int after, + Ordering ordering, + out long totalBefore, + out long totalAfter) + { + IEntitySlim? target = _entityService.Get(targetKey); if (target is null) { totalBefore = 0; @@ -179,8 +218,6 @@ public IEnumerable SiblingUserAccessEntities( return []; } - IEntitySlim[] siblings; - 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. { @@ -189,11 +226,12 @@ public IEnumerable SiblingUserAccessEntities( return []; } - if (userStartNodePaths.Any(path => $"{targetParent?.Path},".StartsWith($"{path},"))) + 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, out totalBefore, out totalAfter, ordering: ordering).ToArray(); - return ChildUserAccessEntities(siblings, userStartNodePaths); + IEnumerable allSiblings = _entityService.GetSiblings(targetKey, umbracoObjectTypes, before, after, out totalBefore, out totalAfter, ordering: ordering); + + return ChildUserAccessEntities(allSiblings, userStartNodePaths); } List allowedSiblingIds = GetAllowedIds(userStartNodePaths, targetParent.Id); @@ -206,10 +244,11 @@ public IEnumerable SiblingUserAccessEntities( return []; } - // Even though we know the IDs of the allowed sibling entities to fetch, we still use a Query to yield correctly sorted children. + // Fetch allowed siblings from all object types IQuery query = _scopeProvider.CreateQuery().Where(x => allowedSiblingIds.Contains(x.Id)); - siblings = _entityService.GetSiblings(targetKey, [umbracoObjectType], before, after, out totalBefore, out totalAfter, query, ordering).ToArray(); - return ChildUserAccessEntities(siblings, userStartNodePaths); + IEnumerable allAllowedSiblings = _entityService.GetSiblings(targetKey, umbracoObjectTypes, before, after, out totalBefore, out totalAfter, query, ordering); + + return ChildUserAccessEntities(allAllowedSiblings, userStartNodePaths); } /// diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Content/PublishableVariantResponseModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Content/PublishableVariantResponseModelBase.cs new file mode 100644 index 000000000000..18c7f95a195b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Content/PublishableVariantResponseModelBase.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Api.Management.ViewModels.Document; + +namespace Umbraco.Cms.Api.Management.ViewModels.Content; + +public abstract class PublishableVariantResponseModelBase : VariantResponseModelBase +{ + public DocumentVariantState State { get; set; } + + public DateTimeOffset? PublishDate { get; set; } + + public DateTimeOffset? ScheduledPublishDate { get; set; } + + public DateTimeOffset? ScheduledUnpublishDate { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs index a58b3cf9bdc6..25931d8ec2ad 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs @@ -2,16 +2,8 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Document; -public class DocumentVariantResponseModel : VariantResponseModelBase, IHasFlags +public class DocumentVariantResponseModel : PublishableVariantResponseModelBase, IHasFlags { - public DocumentVariantState State { get; set; } - - public DateTimeOffset? PublishDate { get; set; } - - public DateTimeOffset? ScheduledPublishDate { get; set; } - - public DateTimeOffset? ScheduledUnpublishDate { get; set; } - private readonly List _flags = []; public Guid Id { get; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantState.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantState.cs index d3edd54cd93b..9bdc55170470 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantState.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantState.cs @@ -3,6 +3,7 @@ /// /// The saved state of a content item /// +// TODO ELEMENTS: move this to ViewModels.Content and rename it to VariantState or ContentVariantState (shared between document and element variants) public enum DocumentVariantState { /// diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentRequestModel.cs index 64cac6202e17..6016bf588aa2 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentRequestModel.cs @@ -5,7 +5,7 @@ public class PublishDocumentRequestModel public required IEnumerable PublishSchedules { get; set; } } - +// TODO ELEMENTS: move the following classes to ViewModels.Content public class CultureAndScheduleRequestModel { /// @@ -19,7 +19,6 @@ public class CultureAndScheduleRequestModel public ScheduleRequestModel? Schedule { get; set; } } - public class ScheduleRequestModel { public DateTimeOffset? PublishTime { get; set; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/CopyElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/CopyElementRequestModel.cs new file mode 100644 index 000000000000..9f6174778cf9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/CopyElementRequestModel.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class CopyElementRequestModel +{ + public ReferenceByIdModel? Target { get; set; } + + // TODO ELEMENTS: do we want a relate-to-original feature for elements? + // public bool RelateToOriginal { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/CreateElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/CreateElementRequestModel.cs new file mode 100644 index 000000000000..35d301973a7c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/CreateElementRequestModel.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class CreateElementRequestModel : CreateContentWithParentRequestModelBase +{ + public required ReferenceByIdModel DocumentType { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementConfigurationResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementConfigurationResponseModel.cs new file mode 100644 index 000000000000..883c6d98c2bc --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementConfigurationResponseModel.cs @@ -0,0 +1,13 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementConfigurationResponseModel +{ + public required bool DisableDeleteWhenReferenced { get; set; } + + public required bool DisableUnpublishWhenReferenced { get; set; } + + public required bool AllowEditInvariantFromNonDefault { get; set; } + + [Obsolete("This functionality will be moved to a client-side extension. Scheduled for removal in V19.")] + public required bool AllowNonExistingSegmentsCreation { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModel.cs new file mode 100644 index 000000000000..92059ecdc4fc --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementResponseModel : ElementResponseModelBase +{ + public bool IsTrashed { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModelBase.cs new file mode 100644 index 000000000000..836a46b415bc --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModelBase.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public abstract class ElementResponseModelBase + : ContentResponseModelBase + where TValueResponseModelBase : ValueModelBase + where TVariantResponseModel : VariantResponseModelBase +{ + public DocumentTypeReferenceResponseModel DocumentType { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueModel.cs new file mode 100644 index 000000000000..1222c4a96592 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementValueModel : ValueModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueResponseModel.cs new file mode 100644 index 000000000000..f4b688332a4e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueResponseModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementValueResponseModel : ValueResponseModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantItemResponseModel.cs new file mode 100644 index 000000000000..84ccfdc1ad45 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantItemResponseModel.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.Document; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementVariantItemResponseModel : VariantItemResponseModelBase +{ + public required DocumentVariantState State { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantRequestModel.cs new file mode 100644 index 000000000000..69b9e252bf29 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantRequestModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementVariantRequestModel : VariantModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantResponseModel.cs new file mode 100644 index 000000000000..af1586761141 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantResponseModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementVariantResponseModel : PublishableVariantResponseModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionItemResponseModel.cs new file mode 100644 index 000000000000..db615a7f7fc6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionItemResponseModel.cs @@ -0,0 +1,41 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementVersionItemResponseModel +{ + public ElementVersionItemResponseModel( + Guid id, + ReferenceByIdModel element, + ReferenceByIdModel documentType, + ReferenceByIdModel user, + DateTimeOffset versionDate, + bool isCurrentPublishedVersion, + bool isCurrentDraftVersion, + bool preventCleanup) + { + Id = id; + Element = element; + DocumentType = documentType; + + User = user; + VersionDate = versionDate; + IsCurrentPublishedVersion = isCurrentPublishedVersion; + IsCurrentDraftVersion = isCurrentDraftVersion; + PreventCleanup = preventCleanup; + } + + public Guid Id { get; } + + public ReferenceByIdModel Element { get; } + + public ReferenceByIdModel DocumentType { get; } + + public ReferenceByIdModel User { get; } + + public DateTimeOffset VersionDate { get; } + + public bool IsCurrentPublishedVersion { get; } + + public bool IsCurrentDraftVersion { get; } + + public bool PreventCleanup { get; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionResponseModel.cs new file mode 100644 index 000000000000..f3fdc39229bb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementVersionResponseModel : ElementResponseModelBase +{ + public ReferenceByIdModel? Element { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/Item/ElementItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/Item/ElementItemResponseModel.cs new file mode 100644 index 000000000000..578db0148f67 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/Item/ElementItemResponseModel.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Item; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element.Item; + +public class ElementItemResponseModel : ItemResponseModelBase +{ + public ReferenceByIdModel? Parent { get; set; } + + public bool HasChildren { get; set; } + + public DocumentTypeReferenceResponseModel DocumentType { get; set; } = new(); + + public IEnumerable Variants { get; set; } = Enumerable.Empty(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/MoveElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/MoveElementRequestModel.cs new file mode 100644 index 000000000000..869e03490ea4 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/MoveElementRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class MoveElementRequestModel +{ + public ReferenceByIdModel? Target { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/PublishElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/PublishElementRequestModel.cs new file mode 100644 index 000000000000..7758cb5578ff --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/PublishElementRequestModel.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Api.Management.ViewModels.Document; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class PublishElementRequestModel +{ + public required IEnumerable PublishSchedules { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/RecycleBin/ElementRecycleBinItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/RecycleBin/ElementRecycleBinItemResponseModel.cs new file mode 100644 index 000000000000..739129c19633 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/RecycleBin/ElementRecycleBinItemResponseModel.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.RecycleBin; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element.RecycleBin; + +public class ElementRecycleBinItemResponseModel : RecycleBinItemResponseModelBase +{ + public DocumentTypeReferenceResponseModel? DocumentType { get; set; } = new(); + + public IEnumerable Variants { get; set; } = Enumerable.Empty(); + + public bool IsFolder { get; set; } + + public string Name { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/UnpublishElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/UnpublishElementRequestModel.cs new file mode 100644 index 000000000000..7951d410d734 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/UnpublishElementRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class UnpublishElementRequestModel +{ + public ISet? Cultures { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/UpdateElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/UpdateElementRequestModel.cs new file mode 100644 index 000000000000..570fdbb0ee0c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/UpdateElementRequestModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class UpdateElementRequestModel : UpdateContentRequestModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ValidateUpdateElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ValidateUpdateElementRequestModel.cs new file mode 100644 index 000000000000..52a4cdcf4537 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ValidateUpdateElementRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ValidateUpdateElementRequestModel : UpdateElementRequestModel +{ + public ISet? Cultures { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderResponseModel.cs index c645c7feb388..78e7c15f1dec 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderResponseModel.cs @@ -3,4 +3,6 @@ public class FolderResponseModel : FolderModelBase { public Guid Id { get; set; } + + public bool IsTrashed { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Folder/Item/FolderItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Folder/Item/FolderItemResponseModel.cs new file mode 100644 index 000000000000..7a4d5d7a3059 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Folder/Item/FolderItemResponseModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Api.Management.ViewModels.Item; + +namespace Umbraco.Cms.Api.Management.ViewModels.Folder.Item; + +public class FolderItemResponseModel : NamedItemResponseModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Folder/MoveFolderRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Folder/MoveFolderRequestModel.cs new file mode 100644 index 000000000000..3a8711a45edc --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Folder/MoveFolderRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Folder; + +public class MoveFolderRequestModel +{ + public ReferenceByIdModel? Target { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/ElementTreeItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/ElementTreeItemResponseModel.cs new file mode 100644 index 000000000000..034285b63612 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/ElementTreeItemResponseModel.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Element; + +namespace Umbraco.Cms.Api.Management.ViewModels.Tree; + +public class ElementTreeItemResponseModel : FolderTreeItemResponseModel +{ + public DateTimeOffset CreateDate { get; set; } + + public DocumentTypeReferenceResponseModel? DocumentType { get; set; } + + public IEnumerable Variants { get; set; } = []; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/FolderTreeItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/FolderTreeItemResponseModel.cs index c92da67ce4d5..d2dc9880e438 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/FolderTreeItemResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/FolderTreeItemResponseModel.cs @@ -3,4 +3,6 @@ public class FolderTreeItemResponseModel : NamedEntityTreeItemResponseModel { public bool IsFolder { get; set; } + + public bool NoAccess { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/CalculatedUserStartNodesResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/CalculatedUserStartNodesResponseModel.cs index 8cc71e84822a..889bd82376a9 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/CalculatedUserStartNodesResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/CalculatedUserStartNodesResponseModel.cs @@ -11,4 +11,8 @@ public class CalculatedUserStartNodesResponseModel public ISet MediaStartNodeIds { get; set; } = new HashSet(); public bool HasMediaRootAccess { get; set; } + + public ISet ElementStartNodeIds { get; set; } = new HashSet(); + + public bool HasElementRootAccess { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/CurrentUserResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/CurrentUserResponseModel.cs index 332e1768d51c..ce4dbd460434 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/CurrentUserResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/CurrentUserResponseModel.cs @@ -16,6 +16,10 @@ public class CurrentUserResponseModel : UserPresentationBase public required bool HasMediaRootAccess { get; init; } + public required ISet ElementStartNodeIds { get; init; } = new HashSet(); + + public required bool HasElementRootAccess { get; init; } + public required IEnumerable AvatarUrls { get; init; } = Enumerable.Empty(); public required IEnumerable Languages { get; init; } = Enumerable.Empty(); diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/UpdateUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/UpdateUserRequestModel.cs index ea65567e23a6..31079f731737 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/UpdateUserRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/UpdateUserRequestModel.cs @@ -11,4 +11,8 @@ public class UpdateUserRequestModel : UserPresentationBase public ISet MediaStartNodeIds { get; set; } = new HashSet(); public bool HasMediaRootAccess { get; init; } + + public ISet ElementStartNodeIds { get; set; } = new HashSet(); + + public bool HasElementRootAccess { get; init; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/UserResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/UserResponseModel.cs index 8177b02d1e5f..726831623faf 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/UserResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/UserResponseModel.cs @@ -16,6 +16,10 @@ public class UserResponseModel : UserPresentationBase public bool HasMediaRootAccess { get; set; } + public ISet ElementStartNodeIds { get; set; } = new HashSet(); + + public bool HasElementRootAccess { get; set; } + public IEnumerable AvatarUrls { get; set; } = Enumerable.Empty(); public UserState State { get; set; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroup/Permissions/ElementPermissionPresentationModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroup/Permissions/ElementPermissionPresentationModel.cs new file mode 100644 index 000000000000..d2af3c7b40f1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroup/Permissions/ElementPermissionPresentationModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions; + +public class ElementPermissionPresentationModel : IPermissionPresentationModel +{ + public required ReferenceByIdModel Element { get; set; } + + public required ISet Verbs { get; set; } +} \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroup/UserGroupBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroup/UserGroupBase.cs index a8d46405ee7c..bed21a19b896 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroup/UserGroupBase.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroup/UserGroupBase.cs @@ -79,6 +79,22 @@ public class UserGroupBase /// public bool MediaRootAccess { get; init; } + /// + /// The key of the element that should act as root node for the user group + /// + /// This can be overwritten by a different user group if a user is a member of multiple groups + /// + /// + public ReferenceByIdModel? ElementStartNode { get; init; } + + /// + /// If the group should have access to the element root. + /// + /// This will be ignored if an explicit start node has been specified in . + /// + /// + public bool ElementRootAccess { get; init; } + /// /// List of permissions provided, and maintained by the front-end. The server has no concept all of them, but some can be used on the server. /// diff --git a/src/Umbraco.Core/Actions/ActionElementBrowse.cs b/src/Umbraco.Core/Actions/ActionElementBrowse.cs new file mode 100644 index 000000000000..4de12c5f6ef6 --- /dev/null +++ b/src/Umbraco.Core/Actions/ActionElementBrowse.cs @@ -0,0 +1,33 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Actions; + +/// +/// This action is used as a security constraint that grants a user the ability to view elements in a tree +/// that has permissions applied to it. +/// +/// +/// This action should not be invoked. It is used as the minimum required permission to view elements in the element tree. +/// By granting a user this permission, the user is able to see the element in the tree but not edit it. +/// +public class ActionElementBrowse : IAction +{ + /// + public const string ActionLetter = "Umb.Element.Read"; + + /// + public const string ActionAlias = "elementbrowse"; + + /// + public string Letter => ActionLetter; + + /// + public string Alias => ActionAlias; + + /// + public bool ShowInNotifier => false; + + /// + public bool CanBePermissionAssigned => true; +} \ No newline at end of file diff --git a/src/Umbraco.Core/Actions/ActionElementCopy.cs b/src/Umbraco.Core/Actions/ActionElementCopy.cs new file mode 100644 index 000000000000..2ce4609402da --- /dev/null +++ b/src/Umbraco.Core/Actions/ActionElementCopy.cs @@ -0,0 +1,28 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Actions; + +/// +/// This action is used as a security constraint that grants a user the ability to copy elements. +/// +public class ActionElementCopy : IAction +{ + /// + public const string ActionLetter = "Umb.Element.Duplicate"; + + /// + public const string ActionAlias = "elementcopy"; + + /// + public string Letter => ActionLetter; + + /// + public string Alias => ActionAlias; + + /// + public bool ShowInNotifier => true; + + /// + public bool CanBePermissionAssigned => true; +} diff --git a/src/Umbraco.Core/Actions/ActionElementDelete.cs b/src/Umbraco.Core/Actions/ActionElementDelete.cs new file mode 100644 index 000000000000..838baba0358c --- /dev/null +++ b/src/Umbraco.Core/Actions/ActionElementDelete.cs @@ -0,0 +1,28 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Actions; + +/// +/// This action is used as a security constraint that grants a user the ability to delete elements. +/// +public class ActionElementDelete : IAction +{ + /// + public const string ActionLetter = "Umb.Element.Delete"; + + /// + public const string ActionAlias = "elementdelete"; + + /// + public string Letter => ActionLetter; + + /// + public string Alias => ActionAlias; + + /// + public bool ShowInNotifier => true; + + /// + public bool CanBePermissionAssigned => true; +} \ No newline at end of file diff --git a/src/Umbraco.Core/Actions/ActionElementMove.cs b/src/Umbraco.Core/Actions/ActionElementMove.cs new file mode 100644 index 000000000000..e16f93245e9e --- /dev/null +++ b/src/Umbraco.Core/Actions/ActionElementMove.cs @@ -0,0 +1,28 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Actions; + +/// +/// This action is used as a security constraint that grants a user the ability to move elements. +/// +public class ActionElementMove : IAction +{ + /// + public const string ActionLetter = "Umb.Element.Move"; + + /// + public const string ActionAlias = "elementmove"; + + /// + public string Letter => ActionLetter; + + /// + public string Alias => ActionAlias; + + /// + public bool ShowInNotifier => true; + + /// + public bool CanBePermissionAssigned => true; +} \ No newline at end of file diff --git a/src/Umbraco.Core/Actions/ActionElementNew.cs b/src/Umbraco.Core/Actions/ActionElementNew.cs new file mode 100644 index 000000000000..06a586534cee --- /dev/null +++ b/src/Umbraco.Core/Actions/ActionElementNew.cs @@ -0,0 +1,28 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Actions; + +/// +/// This action is used as a security constraint that grants a user the ability to create new elements. +/// +public class ActionElementNew : IAction +{ + /// + public const string ActionLetter = "Umb.Element.Create"; + + /// + public const string ActionAlias = "elementcreate"; + + /// + public string Letter => ActionLetter; + + /// + public string Alias => ActionAlias; + + /// + public bool ShowInNotifier => true; + + /// + public bool CanBePermissionAssigned => true; +} \ No newline at end of file diff --git a/src/Umbraco.Core/Actions/ActionElementPublish.cs b/src/Umbraco.Core/Actions/ActionElementPublish.cs new file mode 100644 index 000000000000..125189fbf274 --- /dev/null +++ b/src/Umbraco.Core/Actions/ActionElementPublish.cs @@ -0,0 +1,28 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Actions; + +/// +/// This action is used as a security constraint that grants a user the ability to publish and unpublish elements. +/// +public class ActionElementPublish : IAction +{ + /// + public const string ActionLetter = "Umb.Element.Publish"; + + /// + public const string ActionAlias = "elementpublish"; + + /// + public string Letter => ActionLetter; + + /// + public string Alias => ActionAlias; + + /// + public bool ShowInNotifier => true; + + /// + public bool CanBePermissionAssigned => true; +} \ No newline at end of file diff --git a/src/Umbraco.Core/Actions/ActionElementRollback.cs b/src/Umbraco.Core/Actions/ActionElementRollback.cs new file mode 100644 index 000000000000..5e9b16b1ea75 --- /dev/null +++ b/src/Umbraco.Core/Actions/ActionElementRollback.cs @@ -0,0 +1,28 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Actions; + +/// +/// This action is invoked when an element is being rolled back. +/// +public class ActionElementRollback : IAction +{ + /// + public const string ActionLetter = "Umb.Element.Rollback"; + + /// + public const string ActionAlias = "elementrollback"; + + /// + public string Letter => ActionLetter; + + /// + public string Alias => ActionAlias; + + /// + public bool ShowInNotifier => true; + + /// + public bool CanBePermissionAssigned => true; +} diff --git a/src/Umbraco.Core/Actions/ActionElementUnpublish.cs b/src/Umbraco.Core/Actions/ActionElementUnpublish.cs new file mode 100644 index 000000000000..6d19f6bb620a --- /dev/null +++ b/src/Umbraco.Core/Actions/ActionElementUnpublish.cs @@ -0,0 +1,28 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Actions; + +/// +/// This action is used as a security constraint that grants a user the ability to unpublish elements. +/// +public class ActionElementUnpublish : IAction +{ + /// + public const string ActionLetter = "Umb.Element.Unpublish"; + + /// + public const string ActionAlias = "elementunpublish"; + + /// + public string Letter => ActionLetter; + + /// + public string Alias => ActionAlias; + + /// + public bool ShowInNotifier => false; + + /// + public bool CanBePermissionAssigned => true; +} diff --git a/src/Umbraco.Core/Actions/ActionElementUpdate.cs b/src/Umbraco.Core/Actions/ActionElementUpdate.cs new file mode 100644 index 000000000000..51bc48289338 --- /dev/null +++ b/src/Umbraco.Core/Actions/ActionElementUpdate.cs @@ -0,0 +1,28 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Actions; + +/// +/// This action is used as a security constraint that grants a user the ability to update elements. +/// +public class ActionElementUpdate : IAction +{ + /// + public const string ActionLetter = "Umb.Element.Update"; + + /// + public const string ActionAlias = "elementupdate"; + + /// + public string Letter => ActionLetter; + + /// + public string Alias => ActionAlias; + + /// + public bool ShowInNotifier => true; + + /// + public bool CanBePermissionAssigned => true; +} \ No newline at end of file diff --git a/src/Umbraco.Core/Cache/CacheKeys.cs b/src/Umbraco.Core/Cache/CacheKeys.cs index 0e75b6820db5..ca6c86279f0e 100644 --- a/src/Umbraco.Core/Cache/CacheKeys.cs +++ b/src/Umbraco.Core/Cache/CacheKeys.cs @@ -14,8 +14,10 @@ public static class CacheKeys public const string UserAllContentStartNodesPrefix = "AllContentStartNodes"; public const string UserAllMediaStartNodesPrefix = "AllMediaStartNodes"; + public const string UserAllElementStartNodesPrefix = "AllElementStartNodes"; public const string UserMediaStartNodePathsPrefix = "MediaStartNodePaths"; public const string UserContentStartNodePathsPrefix = "ContentStartNodePaths"; + public const string UserElementStartNodePathsPrefix = "ElementStartNodePaths"; public const string ContentRecycleBinCacheKey = "recycleBin_content"; public const string MediaRecycleBinCacheKey = "recycleBin_media"; diff --git a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs index 3786951370e4..577f922bd05e 100644 --- a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs +++ b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs @@ -227,6 +227,18 @@ public static void RefreshMediaCache(this DistributedCache dc, IEnumerable dc.RefreshByPayload(ElementCacheRefresher.UniqueId, new ElementCacheRefresher.JsonPayload(0, Guid.Empty, TreeChangeTypes.RefreshAll).Yield()); + + + public static void RefreshElementCache(this DistributedCache dc, IEnumerable> changes) + => dc.RefreshByPayload(ElementCacheRefresher.UniqueId, changes.DistinctBy(x => (x.Item.Id, x.Item.Key, x.ChangeTypes)).Select(x => new ElementCacheRefresher.JsonPayload(x.Item.Id, x.Item.Key, x.ChangeTypes))); + + #endregion + #region Published Snapshot public static void RefreshAllPublishedSnapshot(this DistributedCache dc) @@ -234,6 +246,7 @@ public static void RefreshAllPublishedSnapshot(this DistributedCache dc) // note: refresh all content & media caches does refresh content types too dc.RefreshAllContentCache(); dc.RefreshAllMediaCache(); + dc.RefreshAllElementCache(); dc.RefreshAllDomainCache(); } diff --git a/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ElementTreeChangeDistributedCacheNotificationHandler.cs b/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ElementTreeChangeDistributedCacheNotificationHandler.cs new file mode 100644 index 000000000000..ea769fecb10c --- /dev/null +++ b/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ElementTreeChangeDistributedCacheNotificationHandler.cs @@ -0,0 +1,28 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Cache; + +/// +public class ElementTreeChangeDistributedCacheNotificationHandler : TreeChangeDistributedCacheNotificationHandlerBase +{ + private readonly DistributedCache _distributedCache; + + /// + /// Initializes a new instance of the class. + /// + /// The distributed cache. + public ElementTreeChangeDistributedCacheNotificationHandler(DistributedCache distributedCache) + => _distributedCache = distributedCache; + + /// + [Obsolete("Scheduled for removal in Umbraco 18.")] + protected override void Handle(IEnumerable> entities) + => Handle(entities, new Dictionary()); + + /// + protected override void Handle(IEnumerable> entities, IDictionary state) + => _distributedCache.RefreshElementCache(entities); +} diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs new file mode 100644 index 000000000000..ac3bdc84e1aa --- /dev/null +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs @@ -0,0 +1,137 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Cache; + +public sealed class ElementCacheRefresher : PayloadCacheRefresherBase +{ + private readonly IIdKeyMap _idKeyMap; + private readonly IElementCacheService _elementCacheService; + private readonly ICacheManager _cacheManager; + + public ElementCacheRefresher( + AppCaches appCaches, + IJsonSerializer serializer, + IIdKeyMap idKeyMap, + IEventAggregator eventAggregator, + ICacheRefresherNotificationFactory factory, + IElementCacheService elementCacheService, + ICacheManager cacheManager) + : base(appCaches, serializer, eventAggregator, factory) + { + _idKeyMap = idKeyMap; + _elementCacheService = elementCacheService; + + // TODO ELEMENTS: Use IElementsCache instead of ICacheManager, see ContentCacheRefresher for more information. + _cacheManager = cacheManager; + } + + #region Json + + public class JsonPayload + { + public JsonPayload(int id, Guid key, TreeChangeTypes changeTypes) + { + Id = id; + Key = key; + ChangeTypes = changeTypes; + } + + public int Id { get; } + + public Guid Key { get; } + + public TreeChangeTypes ChangeTypes { get; } + + // TODO ELEMENTS: should we support (un)published cultures in this payload? see ContentCacheRefresher.JsonPayload + } + + #endregion + + #region Define + + public static readonly Guid UniqueId = Guid.Parse("EE5BB23A-A656-4F7E-A234-16F21AAABFD1"); + + public override Guid RefresherUniqueId => UniqueId; + + public override string Name => "Element Cache Refresher"; + + #endregion + + #region Refresher + + public override void Refresh(JsonPayload[] payloads) + { + // TODO ELEMENTS: implement recycle bin + // AppCaches.RuntimeCache.ClearByKey(CacheKeys.ElementRecycleBinCacheKey); + + // Ideally, we'd like to not have to clear the entire cache here. However, this was the existing behavior in NuCache. + // The reason for this is that we have no way to know which elements are affected by the changes or what their keys are. + // This is because currently published elements live exclusively in a JSON blob in the umbracoPropertyData table. + // This means that the only way to resolve these keys is to actually parse this data with a specific value converter, and for all cultures, which is not possible. + // If published elements become their own entities with relations, instead of just property data, we can revisit this. + _cacheManager.ElementsCache.Clear(); + + IAppPolicyCache isolatedCache = AppCaches.IsolatedCaches.GetOrCreate(); + + foreach (JsonPayload payload in payloads) + { + // By INT Id + isolatedCache.Clear(RepositoryCacheKeys.GetKey(payload.Id)); + + // By GUID Key + isolatedCache.Clear(RepositoryCacheKeys.GetKey(payload.Key)); + + HandleMemoryCache(payload); + + // TODO ELEMENTS: if we need published status caching for elements (e.g. for seeding purposes), make sure + // it is kept in sync here (see ContentCacheRefresher) + + if (payload.ChangeTypes == TreeChangeTypes.Remove) + { + _idKeyMap.ClearCache(payload.Id); + } + } + + AppCaches.ClearPartialViewCache(); + + base.Refresh(payloads); + } + + private void HandleMemoryCache(JsonPayload payload) + { + if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll)) + { + _elementCacheService.ClearMemoryCacheAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + else if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode) || payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch)) + { + // NOTE: RefreshBranch might be triggered even though elements do not support branch publishing + _elementCacheService.RefreshMemoryCacheAsync(payload.Key).GetAwaiter().GetResult(); + } + + if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) + { + _elementCacheService.RemoveFromMemoryCacheAsync(payload.Key).GetAwaiter().GetResult(); + } + } + + // these events should never trigger + // everything should be JSON + public override void RefreshAll() => throw new NotSupportedException(); + + public override void Refresh(int id) => throw new NotSupportedException(); + + public override void Refresh(Guid id) => throw new NotSupportedException(); + + public override void Remove(int id) => throw new NotSupportedException(); + + #endregion +} diff --git a/src/Umbraco.Core/Constants-Applications.cs b/src/Umbraco.Core/Constants-Applications.cs index 4a53ff455f54..b0f2e75a3942 100644 --- a/src/Umbraco.Core/Constants-Applications.cs +++ b/src/Umbraco.Core/Constants-Applications.cs @@ -46,6 +46,11 @@ public static class Applications /// Application alias for the forms section. /// public const string Forms = "forms"; + + /// + /// Application alias for the library section. + /// + public const string Library = "library"; } /// diff --git a/src/Umbraco.Core/Constants-ObjectTypes.cs b/src/Umbraco.Core/Constants-ObjectTypes.cs index a6b3b2966bd8..9081c023429c 100644 --- a/src/Umbraco.Core/Constants-ObjectTypes.cs +++ b/src/Umbraco.Core/Constants-ObjectTypes.cs @@ -13,6 +13,8 @@ public static class ObjectTypes public static readonly Guid MediaRecycleBin = new(Strings.MediaRecycleBin); + public static readonly Guid ElementRecycleBin = new(Strings.ElementRecycleBin); + public static readonly Guid DataTypeContainer = new(Strings.DataTypeContainer); public static readonly Guid DocumentTypeContainer = new(Strings.DocumentTypeContainer); @@ -31,6 +33,10 @@ public static class ObjectTypes public static readonly Guid DocumentType = new(Strings.DocumentType); + public static readonly Guid Element = new(Strings.Element); + + public static readonly Guid ElementContainer = new(Strings.ElementContainer); + public static readonly Guid Media = new(Strings.Media); public static readonly Guid MediaType = new(Strings.MediaType); @@ -95,6 +101,12 @@ public static class Strings public const string DocumentType = "A2CB7800-F571-4787-9638-BC48539A0EFB"; + public const string Element = "3D7B623C-94B1-487D-8554-A46EC37568BE"; + + public const string ElementContainer = "2815B0CF-9706-499F-AA2A-8A4C7AEF005D"; + + public const string ElementRecycleBin = "A1EE71EB-659C-4EEE-BC97-6243E721CC0D"; + public const string Media = "B796F64C-1F99-4FFB-B886-4BF4BC011A9C"; public const string MediaRecycleBin = "CF3D8E34-1C1C-41e9-AE56-878B57B32113"; diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs index ab1715cf1766..e3a825e209cc 100644 --- a/src/Umbraco.Core/Constants-PropertyEditors.cs +++ b/src/Umbraco.Core/Constants-PropertyEditors.cs @@ -264,6 +264,11 @@ public static class Aliases /// Configuration-less time. /// public const string PlainTime = "Umbraco.Plain.Time"; + + /// + /// Element Picker. + /// + public const string ElementPicker = "Umbraco.ElementPicker"; } /// diff --git a/src/Umbraco.Core/Constants-System.cs b/src/Umbraco.Core/Constants-System.cs index e0ce203052de..f9b80bbff558 100644 --- a/src/Umbraco.Core/Constants-System.cs +++ b/src/Umbraco.Core/Constants-System.cs @@ -84,6 +84,34 @@ public static class System /// public const string RecycleBinMediaPathPrefix = "-1,-21,"; + /// + /// The integer identifier for element's recycle bin. + /// + public const int RecycleBinElement = -22; + + /// + /// The string identifier for element's recycle bin. + /// + /// + /// Use this instead of re-creating the string everywhere. + /// + public const string RecycleBinElementString = "-22"; + + /// + /// The GUID identifier for element's recycle bin. + /// + public static readonly Guid RecycleBinElementKey = new("F055FC2F-C936-4F04-8C9B-5129C58C77D8"); + + /// + /// The string path prefix of the element's recycle bin. + /// + /// + /// Everything that is in the element recycle bin, has a path that starts with the prefix. + /// Use this instead of re-creating the string everywhere. + /// + public const string RecycleBinElementPathPrefix = "-1,-22,"; + + /// /// The default label data type identifier. /// diff --git a/src/Umbraco.Core/Constants-UdiEntityType.cs b/src/Umbraco.Core/Constants-UdiEntityType.cs index c5191b8c25c1..dabf1f10fbeb 100644 --- a/src/Umbraco.Core/Constants-UdiEntityType.cs +++ b/src/Umbraco.Core/Constants-UdiEntityType.cs @@ -35,6 +35,7 @@ public static class UdiEntityType public const string DataTypeContainer = "data-type-container"; public const string Element = "element"; + public const string ElementContainer = "element-container"; public const string Media = "media"; public const string MediaType = "media-type"; public const string MediaTypeContainer = "media-type-container"; diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 54fc5c61b55b..c460cbd5f903 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -298,11 +298,18 @@ private void AddCoreServices() Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); + Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); @@ -419,6 +426,7 @@ private void AddCoreServices() Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton(); + Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton(); diff --git a/src/Umbraco.Core/Extensions/ContentExtensions.cs b/src/Umbraco.Core/Extensions/ContentExtensions.cs index 35b9d945d8cf..659bd964a131 100644 --- a/src/Umbraco.Core/Extensions/ContentExtensions.cs +++ b/src/Umbraco.Core/Extensions/ContentExtensions.cs @@ -228,7 +228,7 @@ public static XElement ToXml(this IContent content, IEntityXmlSerializer seriali /// /// Gets the current status of the Content /// - public static ContentStatus GetStatus(this IContent content, ContentScheduleCollection contentSchedule, string? culture = null) + public static ContentStatus GetStatus(this IPublishableContentBase content, ContentScheduleCollection contentSchedule, string? culture = null) { if (content.Trashed) { diff --git a/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs b/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs index 35ce45c6d3b3..ff64891be9a0 100644 --- a/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs +++ b/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs @@ -32,7 +32,7 @@ public static class PublishedContentExtensions /// The specific culture to get the name for. If null is used the current culture is used (Default is /// null). /// - public static string Name(this IPublishedContent content, IVariationContextAccessor? variationContextAccessor, string? culture = null) + public static string Name(this IPublishedElement content, IVariationContextAccessor? variationContextAccessor, string? culture = null) { if (content == null) { diff --git a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs index 763dde84107f..5098dcbfd853 100644 --- a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs +++ b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs @@ -80,6 +80,10 @@ public static GuidUdi GetUdi(this EntityContainer entity) { entityType = Constants.UdiEntityType.MemberTypeContainer; } + else if (entity.ContainedObjectType == Constants.ObjectTypes.Element) + { + entityType = Constants.UdiEntityType.ElementContainer; + } else { throw new NotSupportedException($"Contained object type {entity.ContainedObjectType} is not supported."); diff --git a/src/Umbraco.Core/Models/Content.cs b/src/Umbraco.Core/Models/Content.cs index 77ddd9e84e9e..8a2965fa91e7 100644 --- a/src/Umbraco.Core/Models/Content.cs +++ b/src/Umbraco.Core/Models/Content.cs @@ -1,6 +1,4 @@ -using System.Collections.Specialized; using System.Runtime.Serialization; -using Umbraco.Extensions; namespace Umbraco.Cms.Core.Models; @@ -9,12 +7,8 @@ namespace Umbraco.Cms.Core.Models; /// [Serializable] [DataContract(IsReference = true)] -public class Content : ContentBase, IContent +public class Content : PublishableContentBase, IContent { - private HashSet? _editedCultures; - private bool _published; - private PublishedState _publishedState; - private ContentCultureInfosCollection? _publishInfos; private int? _templateId; /// @@ -55,13 +49,6 @@ public Content(string name, IContent parent, IContentType contentType, int userI public Content(string name, IContent parent, IContentType contentType, PropertyCollection properties, string? culture = null) : base(name, parent, contentType, properties, culture) { - if (contentType == null) - { - throw new ArgumentNullException(nameof(contentType)); - } - - _publishedState = PublishedState.Unpublished; - PublishedVersionId = 0; } /// @@ -102,13 +89,6 @@ public Content(string name, int parentId, IContentType contentType, int userId, public Content(string? name, int parentId, IContentType? contentType, PropertyCollection properties, string? culture = null) : base(name, parentId, contentType, properties, culture) { - if (contentType == null) - { - throw new ArgumentNullException(nameof(contentType)); - } - - _publishedState = PublishedState.Unpublished; - PublishedVersionId = 0; } /// @@ -126,278 +106,13 @@ public int? TemplateId set => SetPropertyValueAndDetectChanges(value, ref _templateId, nameof(TemplateId)); } - /// - /// Gets or sets a value indicating whether this content item is published or not. - /// - /// - /// the setter is should only be invoked from - /// - the ContentFactory when creating a content entity from a dto - /// - the ContentRepository when updating a content entity - /// - [DataMember] - public bool Published - { - get => _published; - set - { - SetPropertyValueAndDetectChanges(value, ref _published, nameof(Published)); - _publishedState = _published ? PublishedState.Published : PublishedState.Unpublished; - } - } - - /// - /// Gets the published state of the content item. - /// - /// - /// The state should be Published or Unpublished, depending on whether Published - /// is true or false, but can also temporarily be Publishing or Unpublishing when the - /// content item is about to be saved. - /// - [DataMember] - public PublishedState PublishedState - { - get => _publishedState; - set - { - if (value != PublishedState.Publishing && value != PublishedState.Unpublishing) - { - throw new ArgumentException("Invalid state, only Publishing and Unpublishing are accepted."); - } - - _publishedState = value; - } - } - - [IgnoreDataMember] - public bool Edited { get; set; } - - /// - [IgnoreDataMember] - public DateTime? PublishDate { get; set; } // set by persistence - - /// - [IgnoreDataMember] - public int? PublisherId { get; set; } // set by persistence - /// [IgnoreDataMember] public int? PublishTemplateId { get; set; } // set by persistence - /// - [IgnoreDataMember] - public string? PublishName { get; set; } // set by persistence - - /// - [IgnoreDataMember] - public IEnumerable? EditedCultures - { - get => CultureInfos?.Keys.Where(IsCultureEdited); - set => _editedCultures = value == null ? null : new HashSet(value, StringComparer.OrdinalIgnoreCase); - } - - /// - [IgnoreDataMember] - public IEnumerable PublishedCultures => _publishInfos?.Keys ?? []; - - /// - public bool IsCulturePublished(string culture) - - // just check _publishInfos - // a non-available culture could not become published anyways - => !culture.IsNullOrWhiteSpace() && _publishInfos != null && _publishInfos.ContainsKey(culture); - - /// - public bool IsCultureEdited(string culture) - => IsCultureAvailable(culture) && // is available, and - (!IsCulturePublished(culture) || // is not published, or - (_editedCultures != null && _editedCultures.Contains(culture))); // is edited - - /// - [IgnoreDataMember] - public ContentCultureInfosCollection? PublishCultureInfos - { - get - { - if (_publishInfos != null) - { - return _publishInfos; - } - - _publishInfos = new ContentCultureInfosCollection(); - _publishInfos.CollectionChanged += PublishNamesCollectionChanged; - return _publishInfos; - } - - set - { - if (_publishInfos != null) - { - _publishInfos.ClearCollectionChangedEvents(); - } - - _publishInfos = value; - if (_publishInfos != null) - { - _publishInfos.CollectionChanged += PublishNamesCollectionChanged; - } - } - } - - /// - public string? GetPublishName(string? culture) - { - if (culture.IsNullOrWhiteSpace()) - { - return PublishName; - } - - if (!ContentType.VariesByCulture()) - { - return null; - } - - if (_publishInfos == null) - { - return null; - } - - return _publishInfos.TryGetValue(culture!, out ContentCultureInfos infos) ? infos.Name : null; - } - - /// - public DateTime? GetPublishDate(string culture) - { - if (culture.IsNullOrWhiteSpace()) - { - return PublishDate; - } - - if (!ContentType.VariesByCulture()) - { - return null; - } - - if (_publishInfos == null) - { - return null; - } - - return _publishInfos.TryGetValue(culture, out ContentCultureInfos infos) ? infos.Date : null; - } - - [IgnoreDataMember] - public int PublishedVersionId { get; set; } - [DataMember] public bool Blueprint { get; set; } - public override void ResetWereDirtyProperties() - { - base.ResetWereDirtyProperties(); - _previousPublishCultureChanges.updatedCultures = null; - _previousPublishCultureChanges.removedCultures = null; - _previousPublishCultureChanges.addedCultures = null; - } - - public override void ResetDirtyProperties(bool rememberDirty) - { - base.ResetDirtyProperties(rememberDirty); - - if (rememberDirty) - { - _previousPublishCultureChanges.addedCultures = - _currentPublishCultureChanges.addedCultures == null || - _currentPublishCultureChanges.addedCultures.Count == 0 - ? null - : new HashSet(_currentPublishCultureChanges.addedCultures, StringComparer.InvariantCultureIgnoreCase); - _previousPublishCultureChanges.removedCultures = - _currentPublishCultureChanges.removedCultures == null || - _currentPublishCultureChanges.removedCultures.Count == 0 - ? null - : new HashSet(_currentPublishCultureChanges.removedCultures, StringComparer.InvariantCultureIgnoreCase); - _previousPublishCultureChanges.updatedCultures = - _currentPublishCultureChanges.updatedCultures == null || - _currentPublishCultureChanges.updatedCultures.Count == 0 - ? null - : new HashSet(_currentPublishCultureChanges.updatedCultures, StringComparer.InvariantCultureIgnoreCase); - } - else - { - _previousPublishCultureChanges.addedCultures = null; - _previousPublishCultureChanges.removedCultures = null; - _previousPublishCultureChanges.updatedCultures = null; - } - - _currentPublishCultureChanges.addedCultures?.Clear(); - _currentPublishCultureChanges.removedCultures?.Clear(); - _currentPublishCultureChanges.updatedCultures?.Clear(); - - // take care of the published state - _publishedState = _published ? PublishedState.Published : PublishedState.Unpublished; - - if (_publishInfos == null) - { - return; - } - - foreach (ContentCultureInfos infos in _publishInfos) - { - infos.ResetDirtyProperties(rememberDirty); - } - } - - /// - /// Overridden to check special keys. - public override bool IsPropertyDirty(string propertyName) - { - // Special check here since we want to check if the request is for changed cultures - if (propertyName.StartsWith(ChangeTrackingPrefix.PublishedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.PublishedCulture); - return _currentPublishCultureChanges.addedCultures?.Contains(culture) ?? false; - } - - if (propertyName.StartsWith(ChangeTrackingPrefix.UnpublishedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.UnpublishedCulture); - return _currentPublishCultureChanges.removedCultures?.Contains(culture) ?? false; - } - - if (propertyName.StartsWith(ChangeTrackingPrefix.ChangedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.ChangedCulture); - return _currentPublishCultureChanges.updatedCultures?.Contains(culture) ?? false; - } - - return base.IsPropertyDirty(propertyName); - } - - /// - /// Overridden to check special keys. - public override bool WasPropertyDirty(string propertyName) - { - // Special check here since we want to check if the request is for changed cultures - if (propertyName.StartsWith(ChangeTrackingPrefix.PublishedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.PublishedCulture); - return _previousPublishCultureChanges.addedCultures?.Contains(culture) ?? false; - } - - if (propertyName.StartsWith(ChangeTrackingPrefix.UnpublishedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.UnpublishedCulture); - return _previousPublishCultureChanges.removedCultures?.Contains(culture) ?? false; - } - - if (propertyName.StartsWith(ChangeTrackingPrefix.ChangedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.ChangedCulture); - return _previousPublishCultureChanges.updatedCultures?.Contains(culture) ?? false; - } - - return base.WasPropertyDirty(propertyName); - } - /// /// Creates a deep clone of the current entity with its identity and it's property identities reset /// @@ -415,152 +130,4 @@ public IContent DeepCloneWithResetIdentities() return clone; } - - /// - /// Handles culture infos collection changes. - /// - private void PublishNamesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - OnPropertyChanged(nameof(PublishCultureInfos)); - - // we don't need to handle other actions, only add/remove, however we could implement Replace and track updated cultures in _updatedCultures too - // which would allows us to continue doing WasCulturePublished, but don't think we need it anymore - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - { - ContentCultureInfos? cultureInfo = e.NewItems?.Cast().First(); - if (_currentPublishCultureChanges.addedCultures == null) - { - _currentPublishCultureChanges.addedCultures = - new HashSet(StringComparer.InvariantCultureIgnoreCase); - } - - if (_currentPublishCultureChanges.updatedCultures == null) - { - _currentPublishCultureChanges.updatedCultures = - new HashSet(StringComparer.InvariantCultureIgnoreCase); - } - - if (cultureInfo is not null) - { - _currentPublishCultureChanges.addedCultures.Add(cultureInfo.Culture); - _currentPublishCultureChanges.updatedCultures.Add(cultureInfo.Culture); - _currentPublishCultureChanges.removedCultures?.Remove(cultureInfo.Culture); - } - - break; - } - - case NotifyCollectionChangedAction.Remove: - { - // Remove listening for changes - ContentCultureInfos? cultureInfo = e.OldItems?.Cast().First(); - if (_currentPublishCultureChanges.removedCultures == null) - { - _currentPublishCultureChanges.removedCultures = - new HashSet(StringComparer.InvariantCultureIgnoreCase); - } - - if (cultureInfo is not null) - { - _currentPublishCultureChanges.removedCultures.Add(cultureInfo.Culture); - _currentPublishCultureChanges.updatedCultures?.Remove(cultureInfo.Culture); - _currentPublishCultureChanges.addedCultures?.Remove(cultureInfo.Culture); - } - - break; - } - - case NotifyCollectionChangedAction.Replace: - { - // Replace occurs when an Update occurs - ContentCultureInfos? cultureInfo = e.NewItems?.Cast().First(); - if (_currentPublishCultureChanges.updatedCultures == null) - { - _currentPublishCultureChanges.updatedCultures = - new HashSet(StringComparer.InvariantCultureIgnoreCase); - } - - if (cultureInfo is not null) - { - _currentPublishCultureChanges.updatedCultures.Add(cultureInfo.Culture); - } - - break; - } - } - } - - /// - /// Changes the for the current content object - /// - /// New ContentType for this content - /// Leaves PropertyTypes intact after change - internal void ChangeContentType(IContentType contentType) => ChangeContentType(contentType, false); - - /// - /// Changes the for the current content object and removes PropertyTypes, - /// which are not part of the new ContentType. - /// - /// New ContentType for this content - /// Boolean indicating whether to clear PropertyTypes upon change - internal void ChangeContentType(IContentType contentType, bool clearProperties) - { - ChangeContentType(new SimpleContentType(contentType)); - - if (clearProperties) - { - Properties.EnsureCleanPropertyTypes(contentType.CompositionPropertyTypes); - } - else - { - Properties.EnsurePropertyTypes(contentType.CompositionPropertyTypes); - } - - Properties.ClearCollectionChangedEvents(); // be sure not to double add - Properties.CollectionChanged += PropertiesChanged; - } - - protected override void PerformDeepClone(object clone) - { - base.PerformDeepClone(clone); - - var clonedContent = (Content)clone; - - // TODO: need to reset change tracking bits - - // if culture infos exist then deal with event bindings - if (clonedContent._publishInfos != null) - { - // Clear this event handler if any - clonedContent._publishInfos.ClearCollectionChangedEvents(); - - // Manually deep clone - clonedContent._publishInfos = (ContentCultureInfosCollection?)_publishInfos?.DeepClone(); - if (clonedContent._publishInfos is not null) - { - // Re-assign correct event handler - clonedContent._publishInfos.CollectionChanged += clonedContent.PublishNamesCollectionChanged; - } - } - - clonedContent._currentPublishCultureChanges.updatedCultures = null; - clonedContent._currentPublishCultureChanges.addedCultures = null; - clonedContent._currentPublishCultureChanges.removedCultures = null; - - clonedContent._previousPublishCultureChanges.updatedCultures = null; - clonedContent._previousPublishCultureChanges.addedCultures = null; - clonedContent._previousPublishCultureChanges.removedCultures = null; - } - - #region Used for change tracking - - private (HashSet? addedCultures, HashSet? removedCultures, HashSet? updatedCultures) - _currentPublishCultureChanges; - - private (HashSet? addedCultures, HashSet? removedCultures, HashSet? updatedCultures) - _previousPublishCultureChanges; - - #endregion } diff --git a/src/Umbraco.Core/Models/ContentEditing/ElementCreateModel.cs b/src/Umbraco.Core/Models/ContentEditing/ElementCreateModel.cs new file mode 100644 index 000000000000..cc1826108b23 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ElementCreateModel.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ElementCreateModel : ContentCreationModelBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ElementCreateResult.cs b/src/Umbraco.Core/Models/ContentEditing/ElementCreateResult.cs new file mode 100644 index 000000000000..5ba776841391 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ElementCreateResult.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ElementCreateResult : ContentCreateResultBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ElementUpdateModel.cs b/src/Umbraco.Core/Models/ContentEditing/ElementUpdateModel.cs new file mode 100644 index 000000000000..5e69dd41203b --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ElementUpdateModel.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ElementUpdateModel : ContentEditingModelBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ElementUpdateResult.cs b/src/Umbraco.Core/Models/ContentEditing/ElementUpdateResult.cs new file mode 100644 index 000000000000..ce71bb11e03d --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ElementUpdateResult.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ElementUpdateResult : ContentUpdateResultBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ValidateElementUpdateModel.cs b/src/Umbraco.Core/Models/ContentEditing/ValidateElementUpdateModel.cs new file mode 100644 index 000000000000..6c385ff70ed0 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ValidateElementUpdateModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ValidateElementUpdateModel : ElementUpdateModel +{ + public ISet? Cultures { get; set; } +} diff --git a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs index a9307f197208..031edce6d4fe 100644 --- a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs +++ b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs @@ -2,7 +2,7 @@ public sealed class ContentPublishingResult { - public IContent? Content { get; init; } + public IPublishableContentBase? Content { get; init; } public IEnumerable InvalidPropertyAliases { get; set; } = []; } diff --git a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs index c8663e18b154..f1f15d1d7ad6 100644 --- a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs +++ b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs @@ -67,7 +67,7 @@ public static void TouchCulture(this IContentBase content, string? culture) /// these dates assigned to them differ by a couple of Ticks, but we need to ensure they are persisted at the exact /// same time. /// - public static void AdjustDates(this IContent content, DateTime date, bool publishing) + public static void AdjustDates(this IPublishableContentBase content, DateTime date, bool publishing) { if (content.EditedCultures is not null) { @@ -129,7 +129,7 @@ public static void AdjustDates(this IContent content, DateTime date, bool publis /// Gets the cultures that have been flagged for unpublishing. /// /// Gets cultures for which content.UnpublishCulture() has been invoked. - public static IReadOnlyList? GetCulturesUnpublishing(this IContent content) + public static IReadOnlyList? GetCulturesUnpublishing(this IPublishableContentBase content) { if (!content.Published || !content.ContentType.VariesByCulture() || !content.IsPropertyDirty("PublishCultureInfos")) @@ -147,7 +147,7 @@ public static void AdjustDates(this IContent content, DateTime date, bool publis /// /// Copies values from another document. /// - public static void CopyFrom(this IContent content, IContent other, string? culture = "*") + public static void CopyFrom(this IPublishableContentBase content, IPublishableContentBase other, string? culture = "*") { if (other.ContentTypeId != content.ContentTypeId) { @@ -243,7 +243,7 @@ public static void CopyFrom(this IContent content, IContent other, string? cultu } } - public static void SetPublishInfo(this IContent content, string? culture, string? name, DateTime date) + public static void SetPublishInfo(this IPublishableContentBase content, string? culture, string? name, DateTime date) { if (name == null) { @@ -273,7 +273,7 @@ public static void SetPublishInfo(this IContent content, string? culture, string } // sets the edited cultures on the content - public static void SetCultureEdited(this IContent content, IEnumerable? cultures) + public static void SetCultureEdited(this IPublishableContentBase content, IEnumerable? cultures) { if (cultures == null) { @@ -299,7 +299,7 @@ public static void SetCultureEdited(this IContent content, IEnumerable? /// A value indicating whether it was possible to publish the names and values for the specified /// culture(s). The method may fail if required names are not set, but it does NOT validate property data /// - public static bool PublishCulture(this IContent content, CultureImpact? impact, DateTime publishTime, PropertyEditorCollection propertyEditorCollection) + public static bool PublishCulture(this IPublishableContentBase content, CultureImpact? impact, DateTime publishTime, PropertyEditorCollection propertyEditorCollection) { if (impact == null) { @@ -368,7 +368,7 @@ public static bool PublishCulture(this IContent content, CultureImpact? impact, return true; } - private static void PublishPropertyValues(IContent content, IProperty property, string? culture, PropertyEditorCollection propertyEditorCollection) + private static void PublishPropertyValues(IPublishableContentBase content, IProperty property, string? culture, PropertyEditorCollection propertyEditorCollection) { // if the content varies by culture, let data editor opt-in to perform partial property publishing (per culture) if (content.ContentType.VariesByCulture() @@ -390,7 +390,7 @@ private static void PublishPropertyValues(IContent content, IProperty property, /// /// /// - public static bool UnpublishCulture(this IContent content, string? culture = "*") + public static bool UnpublishCulture(this IPublishableContentBase content, string? culture = "*") { culture = culture?.NullOrWhiteSpaceAsNull(); @@ -428,7 +428,7 @@ public static bool UnpublishCulture(this IContent content, string? culture = "*" return keepProcessing; } - public static void ClearPublishInfos(this IContent content) => content.PublishCultureInfos = null; + public static void ClearPublishInfos(this IPublishableContentBase content) => content.PublishCultureInfos = null; /// /// Returns false if the culture is already unpublished @@ -436,7 +436,7 @@ public static bool UnpublishCulture(this IContent content, string? culture = "*" /// /// /// - public static bool ClearPublishInfo(this IContent content, string? culture) + public static bool ClearPublishInfo(this IPublishableContentBase content, string? culture) { if (culture == null) { diff --git a/src/Umbraco.Core/Models/Element.cs b/src/Umbraco.Core/Models/Element.cs new file mode 100644 index 000000000000..6c969162485c --- /dev/null +++ b/src/Umbraco.Core/Models/Element.cs @@ -0,0 +1,108 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Core.Models; + +/// +/// Represents an Element object +/// +[Serializable] +[DataContract(IsReference = true)] +public class Element : PublishableContentBase, IElement +{ + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// ContentType for the current Element object + /// An optional culture. + public Element(string name, IContentType contentType, string? culture = null) + : this(name, contentType, new PropertyCollection(), culture) + { + } + + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// ContentType for the current Element object + /// The identifier of the user creating the Element object + /// An optional culture. + public Element(string name, IContentType contentType, int userId, string? culture = null) + : this(name, contentType, new PropertyCollection(), culture) + { + CreatorId = userId; + WriterId = userId; + } + + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// ContentType for the current Element object + /// Collection of properties + /// An optional culture. + public Element(string name, IContentType contentType, PropertyCollection properties, string? culture = null) + : base(name, Constants.System.Root, contentType, properties, culture) + { + } + + + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// Id of the Parent folder + /// ContentType for the current Element object + /// An optional culture. + public Element(string? name, int parentId, IContentType? contentType, string? culture = null) + : this(name, parentId, contentType, new PropertyCollection(), culture) + { + } + + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// Id of the Parent folder + /// ContentType for the current Element object + /// The identifier of the user creating the Element object + /// An optional culture. + public Element(string name, int parentId, IContentType contentType, int userId, string? culture = null) + : this(name, parentId, contentType, new PropertyCollection(), culture) + { + CreatorId = userId; + WriterId = userId; + } + + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// Id of the Parent folder + /// ContentType for the current Element object + /// Collection of properties + /// An optional culture. + public Element(string? name, int parentId, IContentType? contentType, PropertyCollection properties, string? culture = null) + : base(name, parentId, contentType, properties, culture) + { + } + + /// + /// Creates a deep clone of the current entity with its identity and it's property identities reset + /// + /// + public IElement DeepCloneWithResetIdentities() + { + var clone = (Element)DeepClone(); + clone.Key = Guid.Empty; + clone.VersionId = clone.PublishedVersionId = 0; + clone.ResetIdentity(); + + foreach (IProperty property in clone.Properties) + { + property.ResetIdentity(); + } + + return clone; + } +} diff --git a/src/Umbraco.Core/Models/Entities/DocumentEntitySlim.cs b/src/Umbraco.Core/Models/Entities/DocumentEntitySlim.cs index a5c0ca23c990..8225f7afd53a 100644 --- a/src/Umbraco.Core/Models/Entities/DocumentEntitySlim.cs +++ b/src/Umbraco.Core/Models/Entities/DocumentEntitySlim.cs @@ -3,40 +3,6 @@ namespace Umbraco.Cms.Core.Models.Entities; /// /// Implements . /// -public class DocumentEntitySlim : ContentEntitySlim, IDocumentEntitySlim +public class DocumentEntitySlim : PublishableContentEntitySlim, IDocumentEntitySlim { - private static readonly IReadOnlyDictionary Empty = new Dictionary(); - - private IReadOnlyDictionary? _cultureNames; - private IEnumerable? _editedCultures; - private IEnumerable? _publishedCultures; - - /// - public IReadOnlyDictionary CultureNames - { - get => _cultureNames ?? Empty; - set => _cultureNames = value; - } - - /// - public IEnumerable PublishedCultures - { - get => _publishedCultures ?? Enumerable.Empty(); - set => _publishedCultures = value; - } - - /// - public IEnumerable EditedCultures - { - get => _editedCultures ?? Enumerable.Empty(); - set => _editedCultures = value; - } - - public ContentVariation Variations { get; set; } - - /// - public bool Published { get; set; } - - /// - public bool Edited { get; set; } } diff --git a/src/Umbraco.Core/Models/Entities/ElementEntitySlim.cs b/src/Umbraco.Core/Models/Entities/ElementEntitySlim.cs new file mode 100644 index 000000000000..e812c1cccec6 --- /dev/null +++ b/src/Umbraco.Core/Models/Entities/ElementEntitySlim.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.Entities; + +public class ElementEntitySlim : PublishableContentEntitySlim, IElementEntitySlim +{ +} diff --git a/src/Umbraco.Core/Models/Entities/IDocumentEntitySlim.cs b/src/Umbraco.Core/Models/Entities/IDocumentEntitySlim.cs index 75e16476c25d..71902ef472a3 100644 --- a/src/Umbraco.Core/Models/Entities/IDocumentEntitySlim.cs +++ b/src/Umbraco.Core/Models/Entities/IDocumentEntitySlim.cs @@ -3,35 +3,6 @@ namespace Umbraco.Cms.Core.Models.Entities; /// /// Represents a lightweight document entity, managed by the entity service. /// -public interface IDocumentEntitySlim : IContentEntitySlim +public interface IDocumentEntitySlim : IPublishableContentEntitySlim { - /// - /// Gets the variant name for each culture - /// - IReadOnlyDictionary CultureNames { get; } - - /// - /// Gets the published cultures. - /// - IEnumerable PublishedCultures { get; } - - /// - /// Gets the edited cultures. - /// - IEnumerable EditedCultures { get; } - - /// - /// Gets the content variation of the content type. - /// - ContentVariation Variations { get; } - - /// - /// Gets a value indicating whether the content is published. - /// - bool Published { get; } - - /// - /// Gets a value indicating whether the content has been edited. - /// - bool Edited { get; } } diff --git a/src/Umbraco.Core/Models/Entities/IElementEntitySlim.cs b/src/Umbraco.Core/Models/Entities/IElementEntitySlim.cs new file mode 100644 index 000000000000..15814380a8f5 --- /dev/null +++ b/src/Umbraco.Core/Models/Entities/IElementEntitySlim.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.Entities; + +public interface IElementEntitySlim : IPublishableContentEntitySlim +{ +} diff --git a/src/Umbraco.Core/Models/Entities/IPublishableContentEntitySlim.cs b/src/Umbraco.Core/Models/Entities/IPublishableContentEntitySlim.cs new file mode 100644 index 000000000000..2db5e1ae069e --- /dev/null +++ b/src/Umbraco.Core/Models/Entities/IPublishableContentEntitySlim.cs @@ -0,0 +1,34 @@ +namespace Umbraco.Cms.Core.Models.Entities; + +public interface IPublishableContentEntitySlim : IContentEntitySlim +{ + /// + /// Gets the variant name for each culture + /// + IReadOnlyDictionary CultureNames { get; } + + /// + /// Gets the published cultures. + /// + IEnumerable PublishedCultures { get; } + + /// + /// Gets the edited cultures. + /// + IEnumerable EditedCultures { get; } + + /// + /// Gets the content variation of the content type. + /// + ContentVariation Variations { get; } + + /// + /// Gets a value indicating whether the content is published. + /// + bool Published { get; } + + /// + /// Gets a value indicating whether the content has been edited. + /// + bool Edited { get; } +} diff --git a/src/Umbraco.Core/Models/Entities/PublishableContentEntitySlim.cs b/src/Umbraco.Core/Models/Entities/PublishableContentEntitySlim.cs new file mode 100644 index 000000000000..9653936427eb --- /dev/null +++ b/src/Umbraco.Core/Models/Entities/PublishableContentEntitySlim.cs @@ -0,0 +1,39 @@ +namespace Umbraco.Cms.Core.Models.Entities; + +public abstract class PublishableContentEntitySlim : ContentEntitySlim +{ + private static readonly IReadOnlyDictionary Empty = new Dictionary(); + + private IReadOnlyDictionary? _cultureNames; + private IEnumerable? _editedCultures; + private IEnumerable? _publishedCultures; + + /// + public IReadOnlyDictionary CultureNames + { + get => _cultureNames ?? Empty; + set => _cultureNames = value; + } + + /// + public IEnumerable PublishedCultures + { + get => _publishedCultures ?? Enumerable.Empty(); + set => _publishedCultures = value; + } + + /// + public IEnumerable EditedCultures + { + get => _editedCultures ?? Enumerable.Empty(); + set => _editedCultures = value; + } + + public ContentVariation Variations { get; set; } + + /// + public bool Published { get; set; } + + /// + public bool Edited { get; set; } +} diff --git a/src/Umbraco.Core/Models/EntityContainer.cs b/src/Umbraco.Core/Models/EntityContainer.cs index 02cefdc79584..1c720f755fee 100644 --- a/src/Umbraco.Core/Models/EntityContainer.cs +++ b/src/Umbraco.Core/Models/EntityContainer.cs @@ -14,6 +14,7 @@ public sealed class EntityContainer : TreeEntityBase, IUmbracoEntity { Constants.ObjectTypes.DocumentType, Constants.ObjectTypes.DocumentTypeContainer }, { Constants.ObjectTypes.MediaType, Constants.ObjectTypes.MediaTypeContainer }, { Constants.ObjectTypes.MemberType, Constants.ObjectTypes.MemberTypeContainer }, + { Constants.ObjectTypes.Element, Constants.ObjectTypes.ElementContainer }, }; /// @@ -30,7 +31,7 @@ public EntityContainer(Guid containedObjectType) ParentId = -1; Path = "-1"; - Level = 0; + Level = 1; SortOrder = 0; } diff --git a/src/Umbraco.Core/Models/IContent.cs b/src/Umbraco.Core/Models/IContent.cs index 9e36306cfc78..9570dd745f45 100644 --- a/src/Umbraco.Core/Models/IContent.cs +++ b/src/Umbraco.Core/Models/IContent.cs @@ -6,35 +6,13 @@ namespace Umbraco.Cms.Core.Models; /// /// A document can be published, rendered by a template. /// -public interface IContent : IContentBase +public interface IContent : IPublishableContentBase { /// /// Gets or sets the template id used to render the content. /// int? TemplateId { get; set; } - /// - /// Gets a value indicating whether the content is published. - /// - /// The property tells you which version of the content is currently published. - bool Published { get; set; } - - PublishedState PublishedState { get; set; } - - /// - /// Gets a value indicating whether the content has been edited. - /// - /// - /// Will return `true` once unpublished edits have been made after the version with - /// has been published. - /// - bool Edited { get; set; } - - /// - /// Gets the version identifier for the currently published version of the content. - /// - int PublishedVersionId { get; set; } - /// /// Gets a value indicating whether the content item is a blueprint. /// @@ -46,90 +24,6 @@ public interface IContent : IContentBase /// When editing the content, the template can change, but this will not until the content is published. int? PublishTemplateId { get; set; } - /// - /// Gets the name of the published version of the content. - /// - /// When editing the content, the name can change, but this will not until the content is published. - string? PublishName { get; set; } - - /// - /// Gets the identifier of the user who published the content. - /// - int? PublisherId { get; set; } - - /// - /// Gets the date and time the content was published. - /// - DateTime? PublishDate { get; set; } - - /// - /// Gets the published culture infos of the content. - /// - /// - /// - /// Because a dictionary key cannot be null this cannot get the invariant - /// name, which must be get via the property. - /// - /// - ContentCultureInfosCollection? PublishCultureInfos { get; set; } - - /// - /// Gets the published cultures. - /// - IEnumerable PublishedCultures { get; } - - /// - /// Gets the edited cultures. - /// - IEnumerable? EditedCultures { get; set; } - - /// - /// Gets a value indicating whether a culture is published. - /// - /// - /// - /// A culture becomes published whenever values for this culture are published, - /// and the content published name for this culture is non-null. It becomes non-published - /// whenever values for this culture are unpublished. - /// - /// - /// A culture becomes published as soon as PublishCulture has been invoked, - /// even though the document might not have been saved yet (and can have no identity). - /// - /// Does not support the '*' wildcard (returns false). - /// - bool IsCulturePublished(string culture); - - /// - /// Gets the date a culture was published. - /// - DateTime? GetPublishDate(string culture); - - /// - /// Gets a value indicated whether a given culture is edited. - /// - /// - /// - /// A culture is edited when it is available, and not published or published but - /// with changes. - /// - /// A culture can be edited even though the document might now have been saved yet (and can have no identity). - /// Does not support the '*' wildcard (returns false). - /// - bool IsCultureEdited(string culture); - - /// - /// Gets the name of the published version of the content for a given culture. - /// - /// - /// When editing the content, the name can change, but this will not until the content is published. - /// - /// When is null, gets the invariant - /// language, which is the value of the property. - /// - /// - string? GetPublishName(string? culture); - /// /// Creates a deep clone of the current entity with its identity/alias and it's property identities reset /// diff --git a/src/Umbraco.Core/Models/IElement.cs b/src/Umbraco.Core/Models/IElement.cs new file mode 100644 index 000000000000..610086dfc977 --- /dev/null +++ b/src/Umbraco.Core/Models/IElement.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Models; + +public interface IElement : IPublishableContentBase +{ + /// + /// Creates a deep clone of the current entity with its identity/alias and it's property identities reset + /// + /// + IElement DeepCloneWithResetIdentities(); +} diff --git a/src/Umbraco.Core/Models/IPublishableContentBase.cs b/src/Umbraco.Core/Models/IPublishableContentBase.cs new file mode 100644 index 000000000000..f6e687349af4 --- /dev/null +++ b/src/Umbraco.Core/Models/IPublishableContentBase.cs @@ -0,0 +1,110 @@ +namespace Umbraco.Cms.Core.Models; + +public interface IPublishableContentBase : IContentBase +{ + /// + /// Gets a value indicating whether the content is published. + /// + /// The property tells you which version of the content is currently published. + bool Published { get; set; } + + PublishedState PublishedState { get; set; } + + /// + /// Gets a value indicating whether the content has been edited. + /// + /// + /// Will return `true` once unpublished edits have been made after the version with + /// has been published. + /// + bool Edited { get; set; } + + /// + /// Gets the version identifier for the currently published version of the content. + /// + int PublishedVersionId { get; set; } + + /// + /// Gets the name of the published version of the content. + /// + /// When editing the content, the name can change, but this will not until the content is published. + string? PublishName { get; set; } + + /// + /// Gets the identifier of the user who published the content. + /// + int? PublisherId { get; set; } + + /// + /// Gets the date and time the content was published. + /// + DateTime? PublishDate { get; set; } + + /// + /// Gets the published culture infos of the content. + /// + /// + /// + /// Because a dictionary key cannot be null this cannot get the invariant + /// name, which must be get via the property. + /// + /// + ContentCultureInfosCollection? PublishCultureInfos { get; set; } + + /// + /// Gets the published cultures. + /// + IEnumerable PublishedCultures { get; } + + /// + /// Gets the edited cultures. + /// + IEnumerable? EditedCultures { get; set; } + + /// + /// Gets a value indicating whether a culture is published. + /// + /// + /// + /// A culture becomes published whenever values for this culture are published, + /// and the content published name for this culture is non-null. It becomes non-published + /// whenever values for this culture are unpublished. + /// + /// + /// A culture becomes published as soon as PublishCulture has been invoked, + /// even though the document might not have been saved yet (and can have no identity). + /// + /// Does not support the '*' wildcard (returns false). + /// + bool IsCulturePublished(string culture); + + /// + /// Gets the date a culture was published. + /// + DateTime? GetPublishDate(string culture); + + /// + /// Gets a value indicated whether a given culture is edited. + /// + /// + /// + /// A culture is edited when it is available, and not published or published but + /// with changes. + /// + /// A culture can be edited even though the document might now have been saved yet (and can have no identity). + /// Does not support the '*' wildcard (returns false). + /// + bool IsCultureEdited(string culture); + + /// + /// Gets the name of the published version of the content for a given culture. + /// + /// + /// When editing the content, the name can change, but this will not until the content is published. + /// + /// When is null, gets the invariant + /// language, which is the value of the property. + /// + /// + string? GetPublishName(string? culture); +} diff --git a/src/Umbraco.Core/Models/Membership/IReadOnlyUserGroup.cs b/src/Umbraco.Core/Models/Membership/IReadOnlyUserGroup.cs index 9799412b453e..753e641a81eb 100644 --- a/src/Umbraco.Core/Models/Membership/IReadOnlyUserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/IReadOnlyUserGroup.cs @@ -24,6 +24,8 @@ public interface IReadOnlyUserGroup int? StartMediaId { get; } + int? StartElementId { get; } + // This is set to return true as default to avoid breaking changes. bool HasAccessToAllLanguages => true; diff --git a/src/Umbraco.Core/Models/Membership/IUser.cs b/src/Umbraco.Core/Models/Membership/IUser.cs index 868daee42609..829a0f3c9dfc 100644 --- a/src/Umbraco.Core/Models/Membership/IUser.cs +++ b/src/Umbraco.Core/Models/Membership/IUser.cs @@ -18,6 +18,8 @@ public interface IUser : IMembershipUser, IRememberBeingDirty int[]? StartMediaIds { get; set; } + int[]? StartElementIds { get; set; } + string? Language { get; set; } DateTime? InvitedDate { get; set; } diff --git a/src/Umbraco.Core/Models/Membership/IUserGroup.cs b/src/Umbraco.Core/Models/Membership/IUserGroup.cs index a2dd356be84c..79f74fcb1a91 100644 --- a/src/Umbraco.Core/Models/Membership/IUserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/IUserGroup.cs @@ -24,6 +24,8 @@ public interface IUserGroup : IEntity, IRememberBeingDirty /// int? StartMediaId { get; set; } + int? StartElementId { get; set; } + /// /// The icon /// diff --git a/src/Umbraco.Core/Models/Membership/Permissions/ElementGranularPermission.cs b/src/Umbraco.Core/Models/Membership/Permissions/ElementGranularPermission.cs new file mode 100644 index 000000000000..fa2f656c5a0f --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/Permissions/ElementGranularPermission.cs @@ -0,0 +1,36 @@ +namespace Umbraco.Cms.Core.Models.Membership.Permissions; + +public class ElementGranularPermission : INodeGranularPermission +{ + public const string ContextType = "Element"; + + public required Guid Key { get; set; } + + public string Context => ContextType; + + public required string Permission { get; set; } + + protected bool Equals(ElementGranularPermission other) => Key.Equals(other.Key) && Permission == other.Permission; + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((ElementGranularPermission)obj); + } + + public override int GetHashCode() => HashCode.Combine(Key, Permission); +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Membership/ReadOnlyUserGroup.cs b/src/Umbraco.Core/Models/Membership/ReadOnlyUserGroup.cs index 407edf23b4d2..9ce04f731059 100644 --- a/src/Umbraco.Core/Models/Membership/ReadOnlyUserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/ReadOnlyUserGroup.cs @@ -26,6 +26,40 @@ public ReadOnlyUserGroup( icon, startContentId, startMediaId, + null, + alias, + allowedLanguages, + allowedSections, + permissions, + granularPermissions, + hasAccessToAllLanguages) + { + } + + [Obsolete("Please use the constructor that includes all parameters. Scheduled for removal in Umbraco 19.")] + public ReadOnlyUserGroup( + int id, + Guid key, + string? name, + string? description, + string? icon, + int? startContentId, + int? startMediaId, + string? alias, + IEnumerable allowedLanguages, + IEnumerable allowedSections, + ISet permissions, + ISet granularPermissions, + bool hasAccessToAllLanguages) + : this( + id, + key, + name, + description, + icon, + startContentId, + startMediaId, + null, alias, allowedLanguages, allowedSections, @@ -43,6 +77,7 @@ public ReadOnlyUserGroup( string? icon, int? startContentId, int? startMediaId, + int? startElementId, string? alias, IEnumerable allowedLanguages, IEnumerable allowedSections, @@ -62,6 +97,7 @@ public ReadOnlyUserGroup( // Zero is invalid and will be treated as Null StartContentId = startContentId == 0 ? null : startContentId; StartMediaId = startMediaId == 0 ? null : startMediaId; + StartElementId = startElementId == 0 ? null : startElementId; HasAccessToAllLanguages = hasAccessToAllLanguages; Permissions = permissions; GranularPermissions = granularPermissions; @@ -81,6 +117,8 @@ public ReadOnlyUserGroup( public int? StartMediaId { get; } + public int? StartElementId { get; } + public string Alias { get; } public bool HasAccessToAllLanguages { get; set; } diff --git a/src/Umbraco.Core/Models/Membership/User.cs b/src/Umbraco.Core/Models/Membership/User.cs index 571449dcc38b..a77685ec7e35 100644 --- a/src/Umbraco.Core/Models/Membership/User.cs +++ b/src/Umbraco.Core/Models/Membership/User.cs @@ -38,14 +38,16 @@ public class User : EntityBase, IUser, IProfile private int _sessionTimeout; private int[]? _startContentIds; private int[]? _startMediaIds; + private int[]? _startElementIds; private HashSet _userGroups; private string _username; private UserKind _kind; /// - /// Constructor for creating a new/empty user + /// Initializes a new instance of the class for a new/empty user. /// + /// The global settings. public User(GlobalSettings globalSettings) { SessionTimeout = 60; @@ -55,6 +57,7 @@ public User(GlobalSettings globalSettings) _isLockedOut = false; _startContentIds = []; _startMediaIds = []; + _startElementIds = []; // cannot be null _rawPasswordValue = string.Empty; @@ -64,13 +67,13 @@ public User(GlobalSettings globalSettings) } /// - /// Constructor for creating a new/empty user + /// Initializes a new instance of the class for a new/empty user. /// - /// - /// - /// - /// - /// + /// The global settings. + /// The name. + /// The email. + /// The username. + /// The raw password value. public User(GlobalSettings globalSettings, string? name, string email, string username, string rawPasswordValue) : this(globalSettings) { @@ -103,21 +106,23 @@ public User(GlobalSettings globalSettings, string? name, string email, string us _isLockedOut = false; _startContentIds = []; _startMediaIds = []; + _startElementIds = []; } /// - /// Constructor for creating a new User instance for an existing user + /// Initializes a new instance of the class for an existing user. /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// + /// The global settings. + /// The identifier. + /// The name. + /// The email. + /// The username. + /// The raw password value. + /// The password configuration. + /// The user groups. + /// The start content identifiers. + /// The start media identifiers. + [Obsolete("Use the constructor that includes startElementIds. Scheduled for removal in Umbraco 19.")] public User( GlobalSettings globalSettings, int id, @@ -129,6 +134,36 @@ public User( IEnumerable userGroups, int[] startContentIds, int[] startMediaIds) + : this(globalSettings, id, name, email, username, rawPasswordValue, passwordConfig, userGroups, startContentIds, startMediaIds, []) + { + } + + /// + /// Initializes a new instance of the class for an existing user. + /// + /// The global settings. + /// The identifier. + /// The name. + /// The email. + /// The username. + /// The raw password value. + /// The password configuration. + /// The user groups. + /// The start content identifiers. + /// The start media identifiers. + /// The start element identifiers. + public User( + GlobalSettings globalSettings, + int id, + string? name, + string email, + string? username, + string? rawPasswordValue, + string? passwordConfig, + IEnumerable userGroups, + int[] startContentIds, + int[] startMediaIds, + int[] startElementIds) : this(globalSettings) { // we allow whitespace for this value so just check null @@ -163,6 +198,7 @@ public User( _isLockedOut = false; _startContentIds = startContentIds ?? throw new ArgumentNullException(nameof(startContentIds)); _startMediaIds = startMediaIds ?? throw new ArgumentNullException(nameof(startMediaIds)); + _startElementIds = startElementIds ?? throw new ArgumentNullException(nameof(startElementIds)); } [DataMember] @@ -351,6 +387,20 @@ public int[]? StartMediaIds set => SetPropertyValueAndDetectChanges(value, ref _startMediaIds, nameof(StartMediaIds), IntegerEnumerableComparer); } + /// + /// Gets or sets the start element ids. + /// + /// + /// The start element ids. + /// + [DataMember] + [DoNotClone] + public int[]? StartElementIds + { + get => _startElementIds; + set => SetPropertyValueAndDetectChanges(value, ref _startElementIds, nameof(StartElementIds), IntegerEnumerableComparer); + } + [DataMember] public string? Language { @@ -417,6 +467,7 @@ protected override void PerformDeepClone(object clone) // manually clone the start node props clonedEntity._startContentIds = _startContentIds?.ToArray(); clonedEntity._startMediaIds = _startMediaIds?.ToArray(); + clonedEntity._startElementIds = _startElementIds?.ToArray(); // need to create new collections otherwise they'll get copied by ref clonedEntity._userGroups = new HashSet(_userGroups); diff --git a/src/Umbraco.Core/Models/Membership/UserGroup.cs b/src/Umbraco.Core/Models/Membership/UserGroup.cs index 0b500f9bd3f6..452be1613d77 100644 --- a/src/Umbraco.Core/Models/Membership/UserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/UserGroup.cs @@ -38,6 +38,7 @@ public class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup private List _languageCollection; private int? _startContentId; private int? _startMediaId; + private int? _startElementId; /// /// Constructor to create a new user group @@ -82,6 +83,13 @@ public int? StartMediaId set => SetPropertyValueAndDetectChanges(value, ref _startMediaId, nameof(StartMediaId)); } + [DataMember] + public int? StartElementId + { + get => _startElementId; + set => SetPropertyValueAndDetectChanges(value, ref _startElementId, nameof(StartElementId)); + } + [DataMember] public int? StartContentId { diff --git a/src/Umbraco.Core/Models/Membership/UserGroupExtensions.cs b/src/Umbraco.Core/Models/Membership/UserGroupExtensions.cs index cd6db258970f..cb5580eb07f3 100644 --- a/src/Umbraco.Core/Models/Membership/UserGroupExtensions.cs +++ b/src/Umbraco.Core/Models/Membership/UserGroupExtensions.cs @@ -22,6 +22,7 @@ public static IReadOnlyUserGroup ToReadOnlyGroup(this IUserGroup group) group.Icon, group.StartContentId, group.StartMediaId, + group.StartElementId, group.Alias, group.AllowedLanguages, group.AllowedSections, diff --git a/src/Umbraco.Core/Models/PublishableContentBase.cs b/src/Umbraco.Core/Models/PublishableContentBase.cs new file mode 100644 index 000000000000..75ee915326e7 --- /dev/null +++ b/src/Umbraco.Core/Models/PublishableContentBase.cs @@ -0,0 +1,452 @@ +namespace Umbraco.Cms.Core.Models; + +using System.Collections.Specialized; +using System.Runtime.Serialization; +using Umbraco.Extensions; + +// TODO ELEMENTS: ensure property annotations ect. are up to date from Content +public abstract class PublishableContentBase : ContentBase, IPublishableContentBase +{ + private HashSet? _editedCultures; + private bool _published; + private PublishedState _publishedState; + private ContentCultureInfosCollection? _publishInfos; + + protected PublishableContentBase(string? name, int parentId, IContentTypeComposition? contentType, IPropertyCollection properties, string? culture = null) + : base(name, parentId, contentType, properties, culture) + { + if (contentType == null) + { + throw new ArgumentNullException(nameof(contentType)); + } + + _publishedState = PublishedState.Unpublished; + PublishedVersionId = 0; + } + + protected PublishableContentBase(string? name, IContentBase? parent, IContentTypeComposition contentType, IPropertyCollection properties, string? culture = null) + : base(name, parent, contentType, properties, culture) + { + if (contentType == null) + { + throw new ArgumentNullException(nameof(contentType)); + } + + _publishedState = PublishedState.Unpublished; + PublishedVersionId = 0; + } + + /// + /// Gets or sets a value indicating whether this content item is published or not. + /// + /// + /// the setter is should only be invoked from + /// - the ContentFactory when creating a content entity from a dto + /// - the ContentRepository when updating a content entity + /// + [DataMember] + public bool Published + { + get => _published; + set + { + SetPropertyValueAndDetectChanges(value, ref _published, nameof(Published)); + _publishedState = _published ? PublishedState.Published : PublishedState.Unpublished; + } + } + + /// + /// Gets the published state of the content item. + /// + /// + /// The state should be Published or Unpublished, depending on whether Published + /// is true or false, but can also temporarily be Publishing or Unpublishing when the + /// content item is about to be saved. + /// + [DataMember] + public PublishedState PublishedState + { + get => _publishedState; + set + { + if (value != PublishedState.Publishing && value != PublishedState.Unpublishing) + { + throw new ArgumentException("Invalid state, only Publishing and Unpublishing are accepted."); + } + + _publishedState = value; + } + } + + + [IgnoreDataMember] + public bool Edited { get; set; } + + /// + [IgnoreDataMember] + public DateTime? PublishDate { get; set; } // set by persistence + + /// + [IgnoreDataMember] + public int? PublisherId { get; set; } // set by persistence + + /// + [IgnoreDataMember] + public string? PublishName { get; set; } // set by persistence + + /// + [IgnoreDataMember] + public IEnumerable? EditedCultures + { + get => CultureInfos?.Keys.Where(IsCultureEdited); + set => _editedCultures = value == null ? null : new HashSet(value, StringComparer.OrdinalIgnoreCase); + } + + /// + [IgnoreDataMember] + public IEnumerable PublishedCultures => _publishInfos?.Keys ?? []; + + /// + public bool IsCulturePublished(string culture) + + // just check _publishInfos + // a non-available culture could not become published anyways + => !culture.IsNullOrWhiteSpace() && _publishInfos != null && _publishInfos.ContainsKey(culture); + + /// + public bool IsCultureEdited(string culture) + => IsCultureAvailable(culture) && // is available, and + (!IsCulturePublished(culture) || // is not published, or + (_editedCultures != null && _editedCultures.Contains(culture))); // is edited + + /// + [IgnoreDataMember] + public ContentCultureInfosCollection? PublishCultureInfos + { + get + { + if (_publishInfos != null) + { + return _publishInfos; + } + + _publishInfos = new ContentCultureInfosCollection(); + _publishInfos.CollectionChanged += PublishNamesCollectionChanged; + return _publishInfos; + } + + set + { + if (_publishInfos != null) + { + _publishInfos.ClearCollectionChangedEvents(); + } + + _publishInfos = value; + if (_publishInfos != null) + { + _publishInfos.CollectionChanged += PublishNamesCollectionChanged; + } + } + } + + /// + public string? GetPublishName(string? culture) + { + if (culture.IsNullOrWhiteSpace()) + { + return PublishName; + } + + if (!ContentType.VariesByCulture()) + { + return null; + } + + if (_publishInfos == null) + { + return null; + } + + return _publishInfos.TryGetValue(culture!, out ContentCultureInfos infos) ? infos.Name : null; + } + + /// + public DateTime? GetPublishDate(string culture) + { + if (culture.IsNullOrWhiteSpace()) + { + return PublishDate; + } + + if (!ContentType.VariesByCulture()) + { + return null; + } + + if (_publishInfos == null) + { + return null; + } + + return _publishInfos.TryGetValue(culture, out ContentCultureInfos infos) ? infos.Date : null; + } + + [IgnoreDataMember] + public int PublishedVersionId { get; set; } + + public override void ResetWereDirtyProperties() + { + base.ResetWereDirtyProperties(); + _previousPublishCultureChanges.updatedCultures = null; + _previousPublishCultureChanges.removedCultures = null; + _previousPublishCultureChanges.addedCultures = null; + } + + public override void ResetDirtyProperties(bool rememberDirty) + { + base.ResetDirtyProperties(rememberDirty); + + if (rememberDirty) + { + _previousPublishCultureChanges.addedCultures = + _currentPublishCultureChanges.addedCultures == null || + _currentPublishCultureChanges.addedCultures.Count == 0 + ? null + : new HashSet(_currentPublishCultureChanges.addedCultures, StringComparer.InvariantCultureIgnoreCase); + _previousPublishCultureChanges.removedCultures = + _currentPublishCultureChanges.removedCultures == null || + _currentPublishCultureChanges.removedCultures.Count == 0 + ? null + : new HashSet(_currentPublishCultureChanges.removedCultures, StringComparer.InvariantCultureIgnoreCase); + _previousPublishCultureChanges.updatedCultures = + _currentPublishCultureChanges.updatedCultures == null || + _currentPublishCultureChanges.updatedCultures.Count == 0 + ? null + : new HashSet(_currentPublishCultureChanges.updatedCultures, StringComparer.InvariantCultureIgnoreCase); + } + else + { + _previousPublishCultureChanges.addedCultures = null; + _previousPublishCultureChanges.removedCultures = null; + _previousPublishCultureChanges.updatedCultures = null; + } + + _currentPublishCultureChanges.addedCultures?.Clear(); + _currentPublishCultureChanges.removedCultures?.Clear(); + _currentPublishCultureChanges.updatedCultures?.Clear(); + + // take care of the published state + _publishedState = _published ? PublishedState.Published : PublishedState.Unpublished; + + if (_publishInfos == null) + { + return; + } + + foreach (ContentCultureInfos infos in _publishInfos) + { + infos.ResetDirtyProperties(rememberDirty); + } + } + + /// + /// Overridden to check special keys. + public override bool IsPropertyDirty(string propertyName) + { + // Special check here since we want to check if the request is for changed cultures + if (propertyName.StartsWith(ChangeTrackingPrefix.PublishedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.PublishedCulture); + return _currentPublishCultureChanges.addedCultures?.Contains(culture) ?? false; + } + + if (propertyName.StartsWith(ChangeTrackingPrefix.UnpublishedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.UnpublishedCulture); + return _currentPublishCultureChanges.removedCultures?.Contains(culture) ?? false; + } + + if (propertyName.StartsWith(ChangeTrackingPrefix.ChangedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.ChangedCulture); + return _currentPublishCultureChanges.updatedCultures?.Contains(culture) ?? false; + } + + return base.IsPropertyDirty(propertyName); + } + + /// + /// Overridden to check special keys. + public override bool WasPropertyDirty(string propertyName) + { + // Special check here since we want to check if the request is for changed cultures + if (propertyName.StartsWith(ChangeTrackingPrefix.PublishedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.PublishedCulture); + return _previousPublishCultureChanges.addedCultures?.Contains(culture) ?? false; + } + + if (propertyName.StartsWith(ChangeTrackingPrefix.UnpublishedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.UnpublishedCulture); + return _previousPublishCultureChanges.removedCultures?.Contains(culture) ?? false; + } + + if (propertyName.StartsWith(ChangeTrackingPrefix.ChangedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.ChangedCulture); + return _previousPublishCultureChanges.updatedCultures?.Contains(culture) ?? false; + } + + return base.WasPropertyDirty(propertyName); + } + + /// + /// Handles culture infos collection changes. + /// + private void PublishNamesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnPropertyChanged(nameof(PublishCultureInfos)); + + // we don't need to handle other actions, only add/remove, however we could implement Replace and track updated cultures in _updatedCultures too + // which would allows us to continue doing WasCulturePublished, but don't think we need it anymore + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + ContentCultureInfos? cultureInfo = e.NewItems?.Cast().First(); + if (_currentPublishCultureChanges.addedCultures == null) + { + _currentPublishCultureChanges.addedCultures = + new HashSet(StringComparer.InvariantCultureIgnoreCase); + } + + if (_currentPublishCultureChanges.updatedCultures == null) + { + _currentPublishCultureChanges.updatedCultures = + new HashSet(StringComparer.InvariantCultureIgnoreCase); + } + + if (cultureInfo is not null) + { + _currentPublishCultureChanges.addedCultures.Add(cultureInfo.Culture); + _currentPublishCultureChanges.updatedCultures.Add(cultureInfo.Culture); + _currentPublishCultureChanges.removedCultures?.Remove(cultureInfo.Culture); + } + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + // Remove listening for changes + ContentCultureInfos? cultureInfo = e.OldItems?.Cast().First(); + if (_currentPublishCultureChanges.removedCultures == null) + { + _currentPublishCultureChanges.removedCultures = + new HashSet(StringComparer.InvariantCultureIgnoreCase); + } + + if (cultureInfo is not null) + { + _currentPublishCultureChanges.removedCultures.Add(cultureInfo.Culture); + _currentPublishCultureChanges.updatedCultures?.Remove(cultureInfo.Culture); + _currentPublishCultureChanges.addedCultures?.Remove(cultureInfo.Culture); + } + + break; + } + + case NotifyCollectionChangedAction.Replace: + { + // Replace occurs when an Update occurs + ContentCultureInfos? cultureInfo = e.NewItems?.Cast().First(); + if (_currentPublishCultureChanges.updatedCultures == null) + { + _currentPublishCultureChanges.updatedCultures = + new HashSet(StringComparer.InvariantCultureIgnoreCase); + } + + if (cultureInfo is not null) + { + _currentPublishCultureChanges.updatedCultures.Add(cultureInfo.Culture); + } + + break; + } + } + } + + /// + /// Changes the for the current content object + /// + /// New ContentType for this content + /// Leaves PropertyTypes intact after change + internal void ChangeContentType(IContentType contentType) => ChangeContentType(contentType, false); + + /// + /// Changes the for the current content object and removes PropertyTypes, + /// which are not part of the new ContentType. + /// + /// New ContentType for this content + /// Boolean indicating whether to clear PropertyTypes upon change + internal void ChangeContentType(IContentType contentType, bool clearProperties) + { + ChangeContentType(new SimpleContentType(contentType)); + + if (clearProperties) + { + Properties.EnsureCleanPropertyTypes(contentType.CompositionPropertyTypes); + } + else + { + Properties.EnsurePropertyTypes(contentType.CompositionPropertyTypes); + } + + Properties.ClearCollectionChangedEvents(); // be sure not to double add + Properties.CollectionChanged += PropertiesChanged; + } + + protected override void PerformDeepClone(object clone) + { + base.PerformDeepClone(clone); + + var clonedContent = (PublishableContentBase)clone; + + // TODO: need to reset change tracking bits + + // if culture infos exist then deal with event bindings + if (clonedContent._publishInfos != null) + { + // Clear this event handler if any + clonedContent._publishInfos.ClearCollectionChangedEvents(); + + // Manually deep clone + clonedContent._publishInfos = (ContentCultureInfosCollection?)_publishInfos?.DeepClone(); + if (clonedContent._publishInfos is not null) + { + // Re-assign correct event handler + clonedContent._publishInfos.CollectionChanged += clonedContent.PublishNamesCollectionChanged; + } + } + + clonedContent._currentPublishCultureChanges.updatedCultures = null; + clonedContent._currentPublishCultureChanges.addedCultures = null; + clonedContent._currentPublishCultureChanges.removedCultures = null; + + clonedContent._previousPublishCultureChanges.updatedCultures = null; + clonedContent._previousPublishCultureChanges.addedCultures = null; + clonedContent._previousPublishCultureChanges.removedCultures = null; + } + + #region Used for change tracking + + private (HashSet? addedCultures, HashSet? removedCultures, HashSet? updatedCultures) + _currentPublishCultureChanges; + + private (HashSet? addedCultures, HashSet? removedCultures, HashSet? updatedCultures) + _previousPublishCultureChanges; + + #endregion +} diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs index fa3586a0658d..d6c97a6cbb99 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs @@ -9,93 +9,16 @@ namespace Umbraco.Cms.Core.Models.PublishedContent; /// public interface IPublishedContent : IPublishedElement { - // TODO: IPublishedContent properties colliding with models - // we need to find a way to remove as much clutter as possible from IPublishedContent, - // since this is preventing someone from creating a property named 'Path' and have it - // in a model, for instance. we could move them all under one unique property eg - // Infos, so we would do .Infos.SortOrder - just an idea - not going to do it in v8 - - /// - /// Gets the unique identifier of the content item. - /// - int Id { get; } - - /// - /// Gets the name of the content item for the current culture. - /// - string Name { get; } - /// /// Gets the URL segment of the content item for the current culture. /// string? UrlSegment { get; } - /// - /// Gets the sort order of the content item. - /// - int SortOrder { get; } - - /// - /// Gets the tree level of the content item. - /// - int Level { get; } - - /// - /// Gets the tree path of the content item. - /// - string Path { get; } - /// /// Gets the identifier of the template to use to render the content item. /// int? TemplateId { get; } - /// - /// Gets the identifier of the user who created the content item. - /// - int CreatorId { get; } - - /// - /// Gets the date the content item was created. - /// - DateTime CreateDate { get; } - - /// - /// Gets the identifier of the user who last updated the content item. - /// - int WriterId { get; } - - /// - /// Gets the date the content item was last updated. - /// - /// - /// For published content items, this is also the date the item was published. - /// - /// This date is always global to the content item, see CultureDate() for the - /// date each culture was published. - /// - /// - DateTime UpdateDate { get; } - - /// - /// Gets available culture infos. - /// - /// - /// - /// Contains only those culture that are available. For a published content, these are - /// the cultures that are published. For a draft content, those that are 'available' ie - /// have a non-empty content name. - /// - /// Does not contain the invariant culture. - /// // TODO? - /// - IReadOnlyDictionary Cultures { get; } - - /// - /// Gets the type of the content item (document, media...). - /// - PublishedItemType ItemType { get; } - /// /// Gets the parent of the content item. /// @@ -104,44 +27,18 @@ public interface IPublishedContent : IPublishedElement IPublishedContent? Parent { get; } /// - /// Gets a value indicating whether the content is draft. + /// Gets the children of the content item that are available for the current culture. /// - /// - /// - /// A content is draft when it is the unpublished version of a content, which may - /// have a published version, or not. - /// - /// - /// When retrieving documents from cache in non-preview mode, IsDraft is always false, - /// as only published documents are returned. When retrieving in preview mode, IsDraft can - /// either be true (document is not published, or has been edited, and what is returned - /// is the edited version) or false (document is published, and has not been edited, and - /// what is returned is the published version). - /// - /// - bool IsDraft(string? culture = null); + [Obsolete("Please use either the IPublishedContent.Children() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in Umbraco 18.")] + IEnumerable Children { get; } /// - /// Gets a value indicating whether the content is published. + /// Gets the tree level of the content item. /// - /// - /// A content is published when it has a published version. - /// - /// When retrieving documents from cache in non-preview mode, IsPublished is always - /// true, as only published documents are returned. When retrieving in draft mode, IsPublished - /// can either be true (document has a published version) or false (document has no - /// published version). - /// - /// - /// It is therefore possible for both IsDraft and IsPublished to be true at the same - /// time, meaning that the content is the draft version, and a published version exists. - /// - /// - bool IsPublished(string? culture = null); + int Level { get; } /// - /// Gets the children of the content item that are available for the current culture. + /// Gets the tree path of the content item. /// - [Obsolete("Please use either the IPublishedContent.Children() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in Umbraco 18.")] - IEnumerable Children { get; } + string Path { get; } } diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedElement.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedElement.cs index a198064137dc..3ab2a9993def 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedElement.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedElement.cs @@ -47,4 +47,101 @@ public interface IPublishedElement IPublishedProperty? GetProperty(string alias); #endregion + + /// + /// Gets the unique identifier of the content item. + /// + int Id { get; } + + /// + /// Gets the name of the content item for the current culture. + /// + string Name { get; } + + /// + /// Gets the sort order of the content item. + /// + int SortOrder { get; } + + /// + /// Gets the identifier of the user who created the content item. + /// + int CreatorId { get; } + + /// + /// Gets the date the content item was created. + /// + DateTime CreateDate { get; } + + /// + /// Gets the identifier of the user who last updated the content item. + /// + int WriterId { get; } + + /// + /// Gets the date the content item was last updated. + /// + /// + /// For published content items, this is also the date the item was published. + /// + /// This date is always global to the content item, see CultureDate() for the + /// date each culture was published. + /// + /// + DateTime UpdateDate { get; } + + /// + /// Gets available culture infos. + /// + /// + /// + /// Contains only those culture that are available. For a published content, these are + /// the cultures that are published. For a draft content, those that are 'available' ie + /// have a non-empty content name. + /// + /// Does not contain the invariant culture. + /// // TODO? + /// + IReadOnlyDictionary Cultures { get; } + + /// + /// Gets the type of the content item (document, media...). + /// + PublishedItemType ItemType { get; } + + /// + /// Gets a value indicating whether the content is draft. + /// + /// + /// + /// A content is draft when it is the unpublished version of a content, which may + /// have a published version, or not. + /// + /// + /// When retrieving documents from cache in non-preview mode, IsDraft is always false, + /// as only published documents are returned. When retrieving in preview mode, IsDraft can + /// either be true (document is not published, or has been edited, and what is returned + /// is the edited version) or false (document is published, and has not been edited, and + /// what is returned is the published version). + /// + /// + bool IsDraft(string? culture = null); + + /// + /// Gets a value indicating whether the content is published. + /// + /// + /// A content is published when it has a published version. + /// + /// When retrieving documents from cache in non-preview mode, IsPublished is always + /// true, as only published documents are returned. When retrieving in draft mode, IsPublished + /// can either be true (document has a published version) or false (document has no + /// published version). + /// + /// + /// It is therefore possible for both IsDraft and IsPublished to be true at the same + /// time, meaning that the content is the draft version, and a published version exists. + /// + /// + bool IsPublished(string? culture = null); } diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishableContentBase.cs b/src/Umbraco.Core/Models/PublishedContent/PublishableContentBase.cs new file mode 100644 index 000000000000..565f6a2e7637 --- /dev/null +++ b/src/Umbraco.Core/Models/PublishedContent/PublishableContentBase.cs @@ -0,0 +1,51 @@ +using System.Diagnostics; + +namespace Umbraco.Cms.Core.Models.PublishedContent; + +/// +/// Provide an abstract base class for publishable content implementations (like IPublishedContent and IPublishedElement implementations). +/// +[DebuggerDisplay("Content Id: {Id}")] +public abstract class PublishableContentBase +{ + public abstract IPublishedContentType ContentType { get; } + + /// + public abstract Guid Key { get; } + + /// + public abstract int Id { get; } + + /// + public abstract int SortOrder { get; } + + /// + public abstract int CreatorId { get; } + + /// + public abstract DateTime CreateDate { get; } + + /// + public abstract int WriterId { get; } + + /// + public abstract DateTime UpdateDate { get; } + + /// + public abstract IReadOnlyDictionary Cultures { get; } + + /// + public abstract PublishedItemType ItemType { get; } + + /// + public abstract bool IsDraft(string? culture = null); + + /// + public abstract bool IsPublished(string? culture = null); + + /// + public abstract IEnumerable Properties { get; } + + /// + public abstract IPublishedProperty? GetProperty(string alias); +} diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentBase.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentBase.cs index 3687e1b7f3f2..ccbfb5698a95 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentBase.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentBase.cs @@ -1,7 +1,5 @@ -using System.Diagnostics; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Extensions; @@ -12,21 +10,14 @@ namespace Umbraco.Cms.Core.Models.PublishedContent /// /// This base class does which (a) consistently resolves and caches the URL, (b) provides an implementation /// for this[alias], and (c) provides basic content set management. - [DebuggerDisplay("Content Id: {Id}")] - public abstract class PublishedContentBase : IPublishedContent + // TODO ELEMENTS: correct version for the obsolete message here + [Obsolete("Please implement PublishableContentBase instead. Scheduled for removal in VXX")] + public abstract class PublishedContentBase : PublishableContentBase, IPublishedContent { private readonly IVariationContextAccessor? _variationContextAccessor; protected PublishedContentBase(IVariationContextAccessor? variationContextAccessor) => _variationContextAccessor = variationContextAccessor; - public abstract IPublishedContentType ContentType { get; } - - /// - public abstract Guid Key { get; } - - /// - public abstract int Id { get; } - /// public virtual string Name => this.Name(_variationContextAccessor); @@ -34,9 +25,6 @@ public abstract class PublishedContentBase : IPublishedContent [Obsolete("Please use GetUrlSegment() on IDocumentUrlService instead. Scheduled for removal in V16.")] public virtual string? UrlSegment => this.UrlSegment(_variationContextAccessor); - /// - public abstract int SortOrder { get; } - /// [Obsolete("Not supported for members, scheduled for removal in v17")] public abstract int Level { get; } @@ -48,30 +36,6 @@ public abstract class PublishedContentBase : IPublishedContent /// public abstract int? TemplateId { get; } - /// - public abstract int CreatorId { get; } - - /// - public abstract DateTime CreateDate { get; } - - /// - public abstract int WriterId { get; } - - /// - public abstract DateTime UpdateDate { get; } - - /// - public abstract IReadOnlyDictionary Cultures { get; } - - /// - public abstract PublishedItemType ItemType { get; } - - /// - public abstract bool IsDraft(string? culture = null); - - /// - public abstract bool IsPublished(string? culture = null); - /// [Obsolete("Please use TryGetParentKey() on IDocumentNavigationQueryService or IMediaNavigationQueryService instead. Scheduled for removal in V16.")] public abstract IPublishedContent? Parent { get; } @@ -80,13 +44,6 @@ public abstract class PublishedContentBase : IPublishedContent [Obsolete("Please use TryGetChildrenKeys() on IDocumentNavigationQueryService or IMediaNavigationQueryService instead. Scheduled for removal in V16.")] public virtual IEnumerable Children => GetChildren(); - - /// - public abstract IEnumerable Properties { get; } - - /// - public abstract IPublishedProperty? GetProperty(string alias); - private IEnumerable GetChildren() { INavigationQueryService? navigationQueryService; diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtensionsForModels.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtensionsForModels.cs index 2b123a33a948..4370c7cfd28a 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtensionsForModels.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtensionsForModels.cs @@ -16,6 +16,23 @@ public static class PublishedContentExtensionsForModels public static IPublishedContent? CreateModel( this IPublishedContent? content, IPublishedModelFactory? publishedModelFactory) + => CreateModel(content, publishedModelFactory); + + /// + /// Creates a strongly typed published content model for an internal published element. + /// + /// The internal published element. + /// The published model factory + /// The strongly typed published element model. + public static IPublishedElement? CreateModel( + this IPublishedElement? element, + IPublishedModelFactory? publishedModelFactory) + => CreateModel(element, publishedModelFactory); + + private static T? CreateModel( + IPublishedElement? content, + IPublishedModelFactory? publishedModelFactory) + where T : IPublishedElement { if (publishedModelFactory == null) { @@ -24,7 +41,7 @@ public static class PublishedContentExtensionsForModels if (content == null) { - return null; + return default; } // get model @@ -36,10 +53,10 @@ public static class PublishedContentExtensionsForModels } // if factory returns a different type, throw - if (!(model is IPublishedContent publishedContent)) + if (!(model is T publishedContent)) { throw new InvalidOperationException( - $"Factory returned model of type {model.GetType().FullName} which does not implement IPublishedContent."); + $"Factory returned model of type {model.GetType().FullName} which does not implement {typeof(T).Name}."); } return publishedContent; diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs index 807943edff04..cfaf985c19d5 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs @@ -22,6 +22,7 @@ namespace Umbraco.Cms.Core.Models.PublishedContent; /// wrap and extend another IPublishedContent. /// [DebuggerDisplay("{Id}: {Name} ({ContentType?.Alias})")] +// TODO ELEMENTS: this should probably inherit PublishedElementWrapped, instead of all this code duplication public abstract class PublishedContentWrapped : IPublishedContent { private readonly IPublishedContent _content; diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedElementWrapped.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedElementWrapped.cs index d56230cbfad0..07670b9f0f1a 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedElementWrapped.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedElementWrapped.cs @@ -33,6 +33,28 @@ protected PublishedElementWrapped(IPublishedElement content, IPublishedValueFall /// public IPublishedProperty? GetProperty(string alias) => _content.GetProperty(alias); + public int Id => _content.Id; + + public string Name => _content.Name; + + public int SortOrder => _content.SortOrder; + + public int CreatorId => _content.CreatorId; + + public DateTime CreateDate => _content.CreateDate; + + public int WriterId => _content.WriterId; + + public DateTime UpdateDate => _content.UpdateDate; + + public IReadOnlyDictionary Cultures => _content.Cultures; + + public PublishedItemType ItemType => _content.ItemType; + + public bool IsDraft(string? culture = null) => _content.IsDraft(culture); + + public bool IsPublished(string? culture = null) => _content.IsPublished(culture); + /// /// Gets the wrapped content. /// diff --git a/src/Umbraco.Core/Models/UmbracoObjectTypes.cs b/src/Umbraco.Core/Models/UmbracoObjectTypes.cs index 4339ea404f33..d6cdc2506f56 100644 --- a/src/Umbraco.Core/Models/UmbracoObjectTypes.cs +++ b/src/Umbraco.Core/Models/UmbracoObjectTypes.cs @@ -188,4 +188,20 @@ public enum UmbracoObjectTypes [UmbracoObjectType(Constants.ObjectTypes.Strings.IdReservation)] [FriendlyName("Identifier Reservation")] IdReservation, + + /// + /// Element + /// + [UmbracoObjectType(Constants.ObjectTypes.Strings.Element, typeof(IElement))] + [FriendlyName("Element")] + [UmbracoUdiType(Constants.UdiEntityType.Element)] + Element, + + /// + /// Element container. + /// + [UmbracoObjectType(Constants.ObjectTypes.Strings.ElementContainer)] + [FriendlyName("Element Container")] + [UmbracoUdiType(Constants.UdiEntityType.ElementContainer)] + ElementContainer, } diff --git a/src/Umbraco.Core/Models/UserExtensions.cs b/src/Umbraco.Core/Models/UserExtensions.cs index f1f4064847a6..59353238b8b9 100644 --- a/src/Umbraco.Core/Models/UserExtensions.cs +++ b/src/Umbraco.Core/Models/UserExtensions.cs @@ -93,6 +93,18 @@ internal static bool HasMediaBinAccess(this IUser user, IEntityService entitySer user.CalculateMediaStartNodeIds(entityService, appCaches), Constants.System.RecycleBinMedia); + internal static bool HasElementRootAccess(this IUser user, IEntityService entityService, AppCaches appCaches) => + ContentPermissions.HasPathAccess( + Constants.System.RootString, + user.CalculateElementStartNodeIds(entityService, appCaches), + Constants.System.RecycleBinElement); + + internal static bool HasElementBinAccess(this IUser user, IEntityService entityService, AppCaches appCaches) => + ContentPermissions.HasPathAccess( + Constants.System.RecycleBinElementString, + user.CalculateElementStartNodeIds(entityService, appCaches), + Constants.System.RecycleBinElement); + public static bool HasPathAccess(this IUser user, IMedia? media, IEntityService entityService, AppCaches appCaches) { if (media == null) @@ -126,6 +138,19 @@ public static bool HasMediaPathAccess(this IUser user, IUmbracoEntity entity, IE return ContentPermissions.HasPathAccess(entity.Path, user.CalculateMediaStartNodeIds(entityService, appCaches), Constants.System.RecycleBinMedia); } + public static bool HasElementPathAccess(this IUser user, IUmbracoEntity entity, IEntityService entityService, AppCaches appCaches) + { + if (entity == null) + { + throw new ArgumentNullException(nameof(entity)); + } + + return ContentPermissions.HasPathAccess( + entity.Path, + user.CalculateElementStartNodeIds(entityService, appCaches), + Constants.System.RecycleBinElement); + } + /// /// Determines whether this user has access to view sensitive data /// @@ -211,6 +236,38 @@ public static int[] CalculateAllowedLanguageIds(this IUser user, ILocalizationSe return result; } + /// + /// Calculate start nodes, combining groups' and user's, and excluding what's in the bin + /// + /// + /// + /// + /// + public static int[]? CalculateElementStartNodeIds(this IUser user, IEntityService entityService, AppCaches appCaches) + { + var cacheKey = user.UserCacheKey(CacheKeys.UserAllElementStartNodesPrefix); + IAppPolicyCache runtimeCache = GetUserCache(appCaches); + var result = runtimeCache.GetCacheItem( + cacheKey, + () => + { + var gsn = user.Groups.Where(x => x.StartElementId.HasValue).Select(x => x.StartElementId!.Value).Distinct() + .ToArray(); + var usn = user.StartElementIds; + if (usn is not null) + { + var vals = CombineStartNodes(UmbracoObjectTypes.ElementContainer, gsn, usn, entityService); + return vals; + } + + return null; + }, + TimeSpan.FromMinutes(2), + true); + + return result; + } + public static string[]? GetMediaStartNodePaths(this IUser user, IEntityService entityService, AppCaches appCaches) { var cacheKey = user.UserCacheKey(CacheKeys.UserMediaStartNodePathsPrefix); @@ -248,6 +305,24 @@ public static int[] CalculateAllowedLanguageIds(this IUser user, ILocalizationSe return result; } + public static string[]? GetElementStartNodePaths(this IUser user, IEntityService entityService, AppCaches appCaches) + { + var cacheKey = user.UserCacheKey(CacheKeys.UserElementStartNodePathsPrefix); + IAppPolicyCache runtimeCache = GetUserCache(appCaches); + var result = runtimeCache.GetCacheItem( + cacheKey, + () => + { + var startNodeIds = user.CalculateElementStartNodeIds(entityService, appCaches); + var vals = entityService.GetAllPaths(UmbracoObjectTypes.ElementContainer, startNodeIds).Select(x => x.Path).ToArray(); + return vals; + }, + TimeSpan.FromMinutes(2), + true); + + return result; + } + internal static int[] CombineStartNodes(UmbracoObjectTypes objectType, int[] groupSn, int[] userSn, IEntityService entityService) { // assume groupSn and userSn each don't contain duplicates @@ -336,6 +411,10 @@ private static string GetBinPath(UmbracoObjectTypes objectType) case UmbracoObjectTypes.Media: binPath += Constants.System.RecycleBinMediaString; break; + case UmbracoObjectTypes.Element: + case UmbracoObjectTypes.ElementContainer: + binPath += Constants.System.RecycleBinElementString; + break; default: throw new ArgumentOutOfRangeException(nameof(objectType)); } diff --git a/src/Umbraco.Core/Models/UserUpdateModel.cs b/src/Umbraco.Core/Models/UserUpdateModel.cs index 684cbafda891..53c59106e60a 100644 --- a/src/Umbraco.Core/Models/UserUpdateModel.cs +++ b/src/Umbraco.Core/Models/UserUpdateModel.cs @@ -20,5 +20,9 @@ public class UserUpdateModel public bool HasMediaRootAccess { get; set; } + public ISet ElementStartNodeKeys { get; set; } = new HashSet(); + + public bool HasElementRootAccess { get; set; } + public ISet UserGroupKeys { get; set; } = new HashSet(); } diff --git a/src/Umbraco.Core/Notifications/ElementCacheRefresherNotification.cs b/src/Umbraco.Core/Notifications/ElementCacheRefresherNotification.cs new file mode 100644 index 000000000000..f1937471d399 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementCacheRefresherNotification.cs @@ -0,0 +1,23 @@ +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the Element Cache Refresher. +/// +public class ElementCacheRefresherNotification : CacheRefresherNotification +{ + /// + /// Initializes a new instance of the + /// + /// + /// The refresher payload. + /// + /// + /// Type of the cache refresher message, + /// + public ElementCacheRefresherNotification(object messageObject, MessageType messageType) + : base(messageObject, messageType) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementCopiedNotification.cs b/src/Umbraco.Core/Notifications/ElementCopiedNotification.cs new file mode 100644 index 000000000000..22a4e61d1245 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementCopiedNotification.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// The notification is published after the element has been copied. +/// +public sealed class ElementCopiedNotification : CopiedNotification +{ + public ElementCopiedNotification(IElement original, IElement copy, int parentId, Guid? parentKey, bool relateToOriginal, EventMessages messages) + : base(original, copy, parentId, parentKey, relateToOriginal, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementCopyingNotification.cs b/src/Umbraco.Core/Notifications/ElementCopyingNotification.cs new file mode 100644 index 000000000000..a9f14c6858a3 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementCopyingNotification.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// The notification is published after a copy object has been created and had its parentId updated. +/// +public sealed class ElementCopyingNotification : CopyingNotification +{ + public ElementCopyingNotification(IElement original, IElement copy, int parentId, Guid? parentKey, EventMessages messages) + : base(original, copy, parentId, parentKey, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementDeletedNotification.cs b/src/Umbraco.Core/Notifications/ElementDeletedNotification.cs new file mode 100644 index 000000000000..acafafd2f174 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementDeletedNotification.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the Delete and EmptyRecycleBin methods are called in the API. +/// +public sealed class ElementDeletedNotification : DeletedNotification +{ + public ElementDeletedNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementDeletedVersionsNotification.cs b/src/Umbraco.Core/Notifications/ElementDeletedVersionsNotification.cs new file mode 100644 index 000000000000..f70a2f6bf044 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementDeletedVersionsNotification.cs @@ -0,0 +1,38 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the DeleteVersion and DeleteVersions methods are called in the API, and the version has been deleted. +/// +public sealed class ElementDeletedVersionsNotification : DeletedVersionsNotification +{ + /// + /// Initializes a new instance of the . + /// + /// + /// Gets the ID of the object being deleted. + /// + /// + /// Initializes a new instance of the . + /// + /// + /// Gets the id of the IElement object version being deleted. + /// + /// + /// False by default. + /// + /// + /// Gets the latest version date. + /// + public ElementDeletedVersionsNotification( + int id, + EventMessages messages, + int specificVersion = default, + bool deletePriorVersions = false, + DateTime dateToRetain = default) + : base(id, messages, specificVersion, deletePriorVersions, dateToRetain) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementDeletingNotification.cs b/src/Umbraco.Core/Notifications/ElementDeletingNotification.cs new file mode 100644 index 000000000000..aec5e6419d6c --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementDeletingNotification.cs @@ -0,0 +1,20 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the DeleteElementOfType, Delete and EmptyRecycleBin methods are called in the API. +/// +public sealed class ElementDeletingNotification : DeletingNotification +{ + public ElementDeletingNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + public ElementDeletingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementDeletingVersionsNotification.cs b/src/Umbraco.Core/Notifications/ElementDeletingVersionsNotification.cs new file mode 100644 index 000000000000..0a499fe248c6 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementDeletingVersionsNotification.cs @@ -0,0 +1,33 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the DeleteVersion and DeleteVersions methods are called in the API. +/// +public sealed class ElementDeletingVersionsNotification : DeletingVersionsNotification +{ + /// + /// Initializes a new instance of the . + /// + /// + /// Gets the ID of the object being deleted. + /// + /// + /// Initializes a new instance of the . + /// + /// + /// Gets the id of the IElement object version being deleted. + /// + /// + /// False by default. + /// + /// + /// Gets the latest version date. + /// + public ElementDeletingVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default) + : base(id, messages, specificVersion, deletePriorVersions, dateToRetain) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementEmptiedRecycleBinNotification.cs b/src/Umbraco.Core/Notifications/ElementEmptiedRecycleBinNotification.cs new file mode 100644 index 000000000000..de9e4f88f29f --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementEmptiedRecycleBinNotification.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Core.Events; + +namespace Umbraco.Cms.Core.Notifications; + +public class ElementEmptiedRecycleBinNotification : StatefulNotification +{ + public ElementEmptiedRecycleBinNotification(EventMessages messages) + => Messages = messages; + + public EventMessages Messages { get; } + + public bool Cancel { get; set; } +} diff --git a/src/Umbraco.Core/Notifications/ElementEmptyingRecycleBinNotification.cs b/src/Umbraco.Core/Notifications/ElementEmptyingRecycleBinNotification.cs new file mode 100644 index 000000000000..793582d75373 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementEmptyingRecycleBinNotification.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Core.Events; + +namespace Umbraco.Cms.Core.Notifications; + +public class ElementEmptyingRecycleBinNotification : StatefulNotification, ICancelableNotification +{ + public ElementEmptyingRecycleBinNotification(EventMessages messages) + => Messages = messages; + + public EventMessages Messages { get; } + + public bool Cancel { get; set; } +} diff --git a/src/Umbraco.Core/Notifications/ElementMovedNotification.cs b/src/Umbraco.Core/Notifications/ElementMovedNotification.cs new file mode 100644 index 000000000000..cb1486939437 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementMovedNotification.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// The notification is published and called after the element has been moved. +/// +public sealed class ElementMovedNotification : MovedNotification +{ + public ElementMovedNotification(MoveEventInfo target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementMovedToRecycleBinNotification.cs b/src/Umbraco.Core/Notifications/ElementMovedToRecycleBinNotification.cs new file mode 100644 index 000000000000..e28ad3adcb9f --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementMovedToRecycleBinNotification.cs @@ -0,0 +1,15 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public sealed class ElementMovedToRecycleBinNotification : MovedToRecycleBinNotification +{ + public ElementMovedToRecycleBinNotification(MoveToRecycleBinEventInfo target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementMovingNotification.cs b/src/Umbraco.Core/Notifications/ElementMovingNotification.cs new file mode 100644 index 000000000000..52053cd1da62 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementMovingNotification.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Called while an element is moving, but before it has been moved. Cancel the operation to prevent the movement. +/// +public sealed class ElementMovingNotification : MovingNotification +{ + public ElementMovingNotification(MoveEventInfo target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementMovingToRecycleBinNotification.cs b/src/Umbraco.Core/Notifications/ElementMovingToRecycleBinNotification.cs new file mode 100644 index 000000000000..fe4ee6b8ddc8 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementMovingToRecycleBinNotification.cs @@ -0,0 +1,15 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public sealed class ElementMovingToRecycleBinNotification : MovingToRecycleBinNotification +{ + public ElementMovingToRecycleBinNotification(MoveToRecycleBinEventInfo target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementPublishedNotification.cs b/src/Umbraco.Core/Notifications/ElementPublishedNotification.cs new file mode 100644 index 000000000000..69d9b4713173 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementPublishedNotification.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the Publish method is called in the API and after data has been published. +/// Called after an element has been published. +/// +public sealed class ElementPublishedNotification : EnumerableObjectNotification +{ + public ElementPublishedNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + public ElementPublishedNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Gets a enumeration of which are being published. + /// + public IEnumerable PublishedEntities => Target; +} diff --git a/src/Umbraco.Core/Notifications/ElementPublishingNotification.cs b/src/Umbraco.Core/Notifications/ElementPublishingNotification.cs new file mode 100644 index 000000000000..6e6cdad27353 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementPublishingNotification.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the Publishing method is called in the API. +/// Called while publishing an element but before the element has been published. Cancel the operation to prevent the publish. +/// +public sealed class ElementPublishingNotification : CancelableEnumerableObjectNotification +{ + public ElementPublishingNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + public ElementPublishingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Gets a enumeration of which are being published. + /// + public IEnumerable PublishedEntities => Target; +} diff --git a/src/Umbraco.Core/Notifications/ElementRolledBackNotification.cs b/src/Umbraco.Core/Notifications/ElementRolledBackNotification.cs new file mode 100644 index 000000000000..c381d80de1b9 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementRolledBackNotification.cs @@ -0,0 +1,17 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; +/// +/// A notification that is used to trigger the IElementService when the Rollback method is called in the API, after the content has been rolled back. +/// +public sealed class ElementRolledBackNotification : RolledBackNotification +{ + public ElementRolledBackNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementRollingBackNotification.cs b/src/Umbraco.Core/Notifications/ElementRollingBackNotification.cs new file mode 100644 index 000000000000..9a32b0d9d9f1 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementRollingBackNotification.cs @@ -0,0 +1,17 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; +/// +/// A notification that is used to trigger the IElementService when the Rollback method is called in the API. +/// +public sealed class ElementRollingBackNotification : RollingBackNotification +{ + public ElementRollingBackNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementSavedNotification.cs b/src/Umbraco.Core/Notifications/ElementSavedNotification.cs new file mode 100644 index 000000000000..786d82c85e73 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementSavedNotification.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the Save method is called in the API and after the data has been persisted. +/// +public sealed class ElementSavedNotification : SavedNotification +{ + /// + /// Initializes a new instance of the + /// + public ElementSavedNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Gets a enumeration of . + /// + public ElementSavedNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementSavingNotification.cs b/src/Umbraco.Core/Notifications/ElementSavingNotification.cs new file mode 100644 index 000000000000..8382a321ff03 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementSavingNotification.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the Save method is called in the API. +/// +public sealed class ElementSavingNotification : SavingNotification +{ + /// + /// Initializes a new instance of the + /// + public ElementSavingNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Gets a enumeration of . + /// + public ElementSavingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementTreeChangeNotification.cs b/src/Umbraco.Core/Notifications/ElementTreeChangeNotification.cs new file mode 100644 index 000000000000..75db23a35395 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementTreeChangeNotification.cs @@ -0,0 +1,45 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.Changes; + +namespace Umbraco.Cms.Core.Notifications; + +public sealed class ElementTreeChangeNotification : TreeChangeNotification +{ + public ElementTreeChangeNotification(TreeChange target, EventMessages messages) + : base(target, messages) + { + } + + public ElementTreeChangeNotification(IEnumerable> target, EventMessages messages) + : base( + target, messages) + { + } + + public ElementTreeChangeNotification( + IEnumerable target, + TreeChangeTypes changeTypes, + EventMessages messages) + : base(target.Select(x => new TreeChange(x, changeTypes)), messages) + { + } + + public ElementTreeChangeNotification( + IElement target, + TreeChangeTypes changeTypes, + EventMessages messages) + : base(new TreeChange(target, changeTypes), messages) + { + } + + public ElementTreeChangeNotification( + IElement target, + TreeChangeTypes changeTypes, + IEnumerable? publishedCultures, + IEnumerable? unpublishedCultures, + EventMessages messages) + : base(new TreeChange(target, changeTypes, publishedCultures, unpublishedCultures), messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementUnpublishedNotification.cs b/src/Umbraco.Core/Notifications/ElementUnpublishedNotification.cs new file mode 100644 index 000000000000..17fbf4e67d8e --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementUnpublishedNotification.cs @@ -0,0 +1,24 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the UnPublish method is called in the API and after data has been unpublished. +/// +public sealed class ElementUnpublishedNotification : EnumerableObjectNotification +{ + public ElementUnpublishedNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + public ElementUnpublishedNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } + /// + /// Gets a enumeration of which are being unpublished. + /// + public IEnumerable UnpublishedEntities => Target; +} diff --git a/src/Umbraco.Core/Notifications/ElementUnpublishingNotification.cs b/src/Umbraco.Core/Notifications/ElementUnpublishingNotification.cs new file mode 100644 index 000000000000..67349fcf0b46 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementUnpublishingNotification.cs @@ -0,0 +1,24 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the UnPublishing method is called in the API. +/// +public sealed class ElementUnpublishingNotification : CancelableEnumerableObjectNotification +{ + public ElementUnpublishingNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + public ElementUnpublishingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } + /// + /// Gets a enumeration of which are being unpublished. + /// + public IEnumerable UnpublishedEntities => Target; +} diff --git a/src/Umbraco.Core/Notifications/EntityContainerMovedNotification.cs b/src/Umbraco.Core/Notifications/EntityContainerMovedNotification.cs new file mode 100644 index 000000000000..dc889787f932 --- /dev/null +++ b/src/Umbraco.Core/Notifications/EntityContainerMovedNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public class EntityContainerMovedNotification : MovedNotification +{ + public EntityContainerMovedNotification(MoveEventInfo target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/EntityContainerMovedToRecycleBinNotification.cs b/src/Umbraco.Core/Notifications/EntityContainerMovedToRecycleBinNotification.cs new file mode 100644 index 000000000000..ef9828d542c0 --- /dev/null +++ b/src/Umbraco.Core/Notifications/EntityContainerMovedToRecycleBinNotification.cs @@ -0,0 +1,15 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public sealed class EntityContainerMovedToRecycleBinNotification : MovedToRecycleBinNotification +{ + public EntityContainerMovedToRecycleBinNotification(MoveToRecycleBinEventInfo target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/EntityContainerMovingNotification.cs b/src/Umbraco.Core/Notifications/EntityContainerMovingNotification.cs new file mode 100644 index 000000000000..30b634dae508 --- /dev/null +++ b/src/Umbraco.Core/Notifications/EntityContainerMovingNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public class EntityContainerMovingNotification : MovingNotification +{ + public EntityContainerMovingNotification(MoveEventInfo target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/EntityContainerMovingToRecycleBinNotification.cs b/src/Umbraco.Core/Notifications/EntityContainerMovingToRecycleBinNotification.cs new file mode 100644 index 000000000000..bc745e810273 --- /dev/null +++ b/src/Umbraco.Core/Notifications/EntityContainerMovingToRecycleBinNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public class EntityContainerMovingToRecycleBinNotification : MovingToRecycleBinNotification +{ + public EntityContainerMovingToRecycleBinNotification(MoveToRecycleBinEventInfo target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index f0a9e30f2b46..1896d593bcfc 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -49,6 +49,9 @@ public static class Tables public const string Document = TableNamePrefix + "Document"; public const string DocumentCultureVariation = TableNamePrefix + "DocumentCultureVariation"; public const string DocumentVersion = TableNamePrefix + "DocumentVersion"; + public const string Element = TableNamePrefix + "Element"; + public const string ElementCultureVariation = TableNamePrefix + "ElementCultureVariation"; + public const string ElementVersion = TableNamePrefix + "ElementVersion"; public const string DocumentUrl = TableNamePrefix + "DocumentUrl"; public const string DocumentUrlAlias = TableNamePrefix + "DocumentUrlAlias"; public const string MediaVersion = TableNamePrefix + "MediaVersion"; diff --git a/src/Umbraco.Core/Persistence/Constants-Locks.cs b/src/Umbraco.Core/Persistence/Constants-Locks.cs index 406c4431f151..d38961948425 100644 --- a/src/Umbraco.Core/Persistence/Constants-Locks.cs +++ b/src/Umbraco.Core/Persistence/Constants-Locks.cs @@ -105,5 +105,10 @@ public static class Locks /// All document URL aliases. /// public const int DocumentUrlAliases = -348; + + /// + /// The entire element tree, i.e. all element items. + /// + public const int ElementTree = -349; } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IContentVersionRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IContentVersionRepository.cs new file mode 100644 index 000000000000..fc7158f097b0 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IContentVersionRepository.cs @@ -0,0 +1,36 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IContentVersionRepository : IRepository +{ + /// + /// Gets a list of all historic content versions. + /// + IReadOnlyCollection GetContentVersionsEligibleForCleanup(); + + /// + /// Gets cleanup policy override settings per content type. + /// + IReadOnlyCollection GetCleanupPolicies(); + + /// + /// Gets paginated content versions for given content id paginated. + /// + IEnumerable GetPagedItemsByContentId(int contentId, long pageIndex, int pageSize, out long totalRecords, int? languageId = null); + + /// + /// Deletes multiple content versions by ID. + /// + void DeleteVersions(IEnumerable versionIds); + + /// + /// Updates the prevent cleanup flag on a content version. + /// + void SetPreventCleanup(int versionId, bool preventCleanup); + + /// + /// Gets the content version metadata for a specific version. + /// + ContentVersionMeta? Get(int versionId); +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IDocumentRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IDocumentRepository.cs index cc999b5c17fb..18e731223c7b 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IDocumentRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IDocumentRepository.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Persistence.Querying; @@ -6,7 +5,7 @@ namespace Umbraco.Cms.Core.Persistence.Repositories; -public interface IDocumentRepository : IContentRepository, IReadRepository +public interface IDocumentRepository : IPublishableContentRepository { /// /// Gets paged documents. @@ -35,73 +34,7 @@ IEnumerable GetPage( string[]? propertyAliases, IQuery? filter, Ordering? ordering, - bool loadTemplates) - => GetPage(query, pageIndex, pageSize, out totalRecords, propertyAliases, filter, ordering); - - /// - /// Gets publish/unpublish schedule for a content node. - /// - /// - /// - /// - /// - ContentScheduleCollection GetContentSchedule(int contentId); - - /// - /// Persists publish/unpublish schedule for a content node. - /// - /// - /// - void PersistContentSchedule(IContent content, ContentScheduleCollection schedule); - - /// - /// Clears the publishing schedule for all entries having an a date before (lower than, or equal to) a specified date. - /// - void ClearSchedule(DateTime date); - - void ClearSchedule(DateTime date, ContentScheduleAction action); - - bool HasContentForExpiration(DateTime date); - - bool HasContentForRelease(DateTime date); - - /// - /// Gets objects having an expiration date before (lower than, or equal to) a specified date. - /// - /// - /// The content returned from this method may be culture variant, in which case you can use - /// to get the status for a specific culture. - /// - IEnumerable GetContentForExpiration(DateTime date); - - /// - /// Gets objects having a release date before (lower than, or equal to) a specified date. - /// - /// - /// The content returned from this method may be culture variant, in which case you can use - /// to get the status for a specific culture. - /// - IEnumerable GetContentForRelease(DateTime date); - - /// - /// Gets the content keys from the provided collection of keys that are scheduled for publishing. - /// - /// The IDs of the documents. - /// - /// The provided collection of content keys filtered for those that are scheduled for publishing. - /// - IDictionary> GetContentSchedulesByIds(int[] documentIds) => ImmutableDictionary>.Empty; - - /// - /// Get the count of published items - /// - /// - /// - /// We require this on the repo because the IQuery{IContent} cannot supply the 'newest' parameter - /// - int CountPublished(string? contentTypeAlias = null); - - bool IsPathPublished(IContent? content); + bool loadTemplates); /// /// Used to bulk update the permissions set for a content item. This will replace all permissions diff --git a/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs index 9902c8df302c..492831443604 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs @@ -1,36 +1,5 @@ -using Umbraco.Cms.Core.Models; - namespace Umbraco.Cms.Core.Persistence.Repositories; -public interface IDocumentVersionRepository : IRepository +public interface IDocumentVersionRepository : IContentVersionRepository { - /// - /// Gets a list of all historic content versions. - /// - public IReadOnlyCollection GetDocumentVersionsEligibleForCleanup(); - - /// - /// Gets cleanup policy override settings per content type. - /// - public IReadOnlyCollection GetCleanupPolicies(); - - /// - /// Gets paginated content versions for given content id paginated. - /// - public IEnumerable GetPagedItemsByContentId(int contentId, long pageIndex, int pageSize, out long totalRecords, int? languageId = null); - - /// - /// Deletes multiple content versions by ID. - /// - void DeleteVersions(IEnumerable versionIds); - - /// - /// Updates the prevent cleanup flag on a content version. - /// - void SetPreventCleanup(int versionId, bool preventCleanup); - - /// - /// Gets the content version metadata for a specific version. - /// - ContentVersionMeta? Get(int versionId); } diff --git a/src/Umbraco.Core/Persistence/Repositories/IElementContainerRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IElementContainerRepository.cs new file mode 100644 index 000000000000..85eeaaf09dca --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IElementContainerRepository.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IElementContainerRepository : IEntityContainerRepository +{ +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IElementRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IElementRepository.cs new file mode 100644 index 000000000000..cf9f9f2dc9e0 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IElementRepository.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IElementRepository : IPublishableContentRepository +{ +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IElementVersionRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IElementVersionRepository.cs new file mode 100644 index 000000000000..1aedb2aeb0e7 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IElementVersionRepository.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IElementVersionRepository : IContentVersionRepository +{ +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index 9f8640c3c258..aeb4f26c75a9 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -17,8 +17,26 @@ public interface IEntityRepository : IRepository IEnumerable GetAll(Guid objectType, params int[] ids); + /// + /// Gets entities of multiple object types. + /// + /// The object types of the entities. + /// The identifiers of the entities. + /// If is empty, returns all entities of the specified types. + IEnumerable GetAll(IEnumerable objectTypes, params int[] ids) + => throw new NotImplementedException(); // TODO (V19): Remove default implementation. + IEnumerable GetAll(Guid objectType, params Guid[] keys); + /// + /// Gets entities of multiple object types. + /// + /// The object types of the entities. + /// The unique identifiers of the entities. + /// If is empty, returns all entities of the specified types. + IEnumerable GetAll(IEnumerable objectTypes, params Guid[] keys) + => throw new NotImplementedException(); // TODO (V19): Remove default implementation. + /// /// Gets sibling entities of a specified target entity, within a given range before and after the target, ordered as specified. /// diff --git a/src/Umbraco.Core/Persistence/Repositories/IPublishableContentRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IPublishableContentRepository.cs new file mode 100644 index 000000000000..502483edc4f8 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IPublishableContentRepository.cs @@ -0,0 +1,76 @@ +using System.Collections.Immutable; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +// TODO ELEMENTS: fully define this interface +public interface IPublishableContentRepository : IContentRepository, + IReadRepository + where TContent : IPublishableContentBase +{ + /// + /// Gets publish/unpublish schedule for a content node. + /// + /// + /// + /// + /// + ContentScheduleCollection GetContentSchedule(int contentId); + + /// + /// Persists publish/unpublish schedule for a content node. + /// + /// + /// + void PersistContentSchedule(IPublishableContentBase content, ContentScheduleCollection schedule); + + /// + /// Clears the publishing schedule for all entries having an a date before (lower than, or equal to) a specified date. + /// + void ClearSchedule(DateTime date); + + void ClearSchedule(DateTime date, ContentScheduleAction action); + + bool HasContentForExpiration(DateTime date); + + bool HasContentForRelease(DateTime date); + + /// + /// Gets objects having an expiration date before (lower than, or equal to) a specified date. + /// + /// + /// The content returned from this method may be culture variant, in which case you can use + /// to get the status for a specific culture. + /// + IEnumerable GetContentForExpiration(DateTime date); + + /// + /// Gets objects having a release date before (lower than, or equal to) a specified date. + /// + /// + /// The content returned from this method may be culture variant, in which case you can use + /// to get the status for a specific culture. + /// + IEnumerable GetContentForRelease(DateTime date); + + /// + /// Get the count of published items + /// + /// + /// + /// We require this on the repo because the IQuery{TContent} cannot supply the 'newest' parameter + /// + int CountPublished(string? contentTypeAlias = null); + + bool IsPathPublished(TContent? content); + + /// + /// Gets the content keys from the provided collection of keys that are scheduled for publishing. + /// + /// The IDs of the content items. + /// + /// The provided collection of content keys filtered for those that are scheduled for publishing. + /// + IDictionary> GetContentSchedulesByIds(int[] contentIds) => ImmutableDictionary>.Empty; +} + diff --git a/src/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditor.cs new file mode 100644 index 000000000000..1cbaed584cff --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditor.cs @@ -0,0 +1,39 @@ +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Element picker property editor that stores element keys +/// +[DataEditor( + Constants.PropertyEditors.Aliases.ElementPicker, + ValueType = ValueTypes.Json, + ValueEditorIsReusable = true)] +public class ElementPickerPropertyEditor : DataEditor +{ + public ElementPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) + : base(dataValueEditorFactory) + => SupportsReadOnly = true; + + protected override IDataValueEditor CreateValueEditor() => + DataValueEditorFactory.Create(Attribute!); + + internal sealed class ElementPickerPropertyValueEditor : DataValueEditor, IDataValueReference + { + public ElementPickerPropertyValueEditor( + IShortStringHelper shortStringHelper, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper, + DataEditorAttribute attribute) + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) + { + } + + // TODO ELEMENTS: implement reference tracking from element picker + public IEnumerable GetReferences(object? value) => []; + } +} diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/ElementPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/ElementPickerValueConverter.cs new file mode 100644 index 000000000000..6ca2f26dac30 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/ElementPickerValueConverter.cs @@ -0,0 +1,75 @@ +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +public class ElementPickerValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter +{ + private readonly IJsonSerializer _jsonSerializer; + private readonly IElementCacheService _elementCacheService; + private readonly IVariationContextAccessor _variationContextAccessor; + + public ElementPickerValueConverter(IJsonSerializer jsonSerializer, IElementCacheService elementCacheService, IVariationContextAccessor variationContextAccessor) + { + _jsonSerializer = jsonSerializer; + _elementCacheService = elementCacheService; + _variationContextAccessor = variationContextAccessor; + } + + public override bool IsConverter(IPublishedPropertyType propertyType) + => Constants.PropertyEditors.Aliases.ElementPicker.Equals(propertyType.EditorAlias); + + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) + => typeof(IEnumerable); + + public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) + => PropertyCacheLevel.Elements; + + public override bool? IsValue(object? value, PropertyValueLevel level) => + value is not null && value.ToString() != "[]"; + + public override object ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview) + => source?.ToString()!; + + public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + var value = inter as string; + if (value.IsNullOrWhiteSpace()) + { + return null; + } + + Guid[]? keys = _jsonSerializer.Deserialize(value); + if (keys is null) + { + return null; + } + + IEnumerable elements = keys + .Select(key => _elementCacheService.GetByKeyAsync(key, preview).GetAwaiter().GetResult()) + .WhereNotNull(); + + if (preview is false && _variationContextAccessor.VariationContext?.Culture is not null) + { + elements = elements + .Where(element => element.IsPublished(_variationContextAccessor.VariationContext.Culture)); + } + + return elements.ToArray(); + } + + // TODO ELEMENTS: implement Delivery API + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) + => GetPropertyCacheLevel(propertyType); + + // TODO ELEMENTS: implement Delivery API + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) + => GetPropertyValueType(propertyType); + + // TODO ELEMENTS: implement Delivery API + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) + => null; +} diff --git a/src/Umbraco.Core/PublishedCache/IElementCacheService.cs b/src/Umbraco.Core/PublishedCache/IElementCacheService.cs new file mode 100644 index 000000000000..ee30cefb55da --- /dev/null +++ b/src/Umbraco.Core/PublishedCache/IElementCacheService.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.PublishedCache; + +// TODO ELEMENTS: refactor IDocumentCacheService into a common base interface and use it both here and for IDocumentCacheService +public interface IElementCacheService +{ + Task GetByKeyAsync(Guid key, bool? preview = null); + + Task ClearMemoryCacheAsync(CancellationToken cancellationToken); + + Task RefreshMemoryCacheAsync(Guid key); + + Task RemoveFromMemoryCacheAsync(Guid key); +} diff --git a/src/Umbraco.Core/PublishedCache/PublishedElement.cs b/src/Umbraco.Core/PublishedCache/PublishedElement.cs index c8d8acab4d2e..a4e962a9e1e0 100644 --- a/src/Umbraco.Core/PublishedCache/PublishedElement.cs +++ b/src/Umbraco.Core/PublishedCache/PublishedElement.cs @@ -45,6 +45,10 @@ public PublishedElement(IPublishedContentType contentType, Guid key, Dictionary< }) .ToArray() ?? []; + + Name = string.Empty; + Path = string.Empty; + Cultures = new Dictionary(); } // initializes a new instance of the PublishedElement class @@ -79,4 +83,20 @@ public PublishedElement(IPublishedContentType contentType, Guid key, Dictionary< IPublishedProperty? property = index < 0 ? null : _propertiesArray?[index]; return property; } + + // TODO ELEMENTS: figure out what to do with all these + // perhaps replace this whole class with PublishedElementWrapped? in that case, we also need to do the same for PublishedContent + public int Id { get; } + public string Name { get; } + public int SortOrder { get; } + public int Level { get; } + public string Path { get; } + public int CreatorId { get; } + public DateTime CreateDate { get; } + public int WriterId { get; } + public DateTime UpdateDate { get; } + public IReadOnlyDictionary Cultures { get; } + public PublishedItemType ItemType { get; } + public bool IsDraft(string? culture = null) => throw new NotImplementedException(); + public bool IsPublished(string? culture = null) => throw new NotImplementedException(); } diff --git a/src/Umbraco.Core/Security/Authorization/ElementPermissionAuthorizer.cs b/src/Umbraco.Core/Security/Authorization/ElementPermissionAuthorizer.cs new file mode 100644 index 000000000000..b50a5f02cb19 --- /dev/null +++ b/src/Umbraco.Core/Security/Authorization/ElementPermissionAuthorizer.cs @@ -0,0 +1,77 @@ +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.AuthorizationStatus; + +namespace Umbraco.Cms.Core.Security.Authorization; + +/// +internal sealed class ElementPermissionAuthorizer : IElementPermissionAuthorizer +{ + private readonly IElementPermissionService _elementPermissionService; + + public ElementPermissionAuthorizer(IElementPermissionService elementPermissionService) => + _elementPermissionService = elementPermissionService; + + /// + public async Task IsDeniedAsync( + IUser currentUser, + IEnumerable elementKeys, + ISet permissionsToCheck) + { + var elementKeyList = elementKeys.ToList(); + if (elementKeyList.Count == 0) + { + // Must succeed this requirement since we cannot process it. + return true; + } + + ElementAuthorizationStatus result = + await _elementPermissionService.AuthorizeAccessAsync(currentUser, elementKeyList, permissionsToCheck); + + // If we can't find the element item(s) then we can't determine whether you are denied access. + return result is not (ElementAuthorizationStatus.Success or ElementAuthorizationStatus.NotFound); + } + + /// + public async Task IsDeniedWithDescendantsAsync( + IUser currentUser, + Guid parentKey, + ISet permissionsToCheck) + { + ElementAuthorizationStatus result = + await _elementPermissionService.AuthorizeDescendantsAccessAsync(currentUser, parentKey, permissionsToCheck); + + // If we can't find the element item(s) then we can't determine whether you are denied access. + return result is not (ElementAuthorizationStatus.Success or ElementAuthorizationStatus.NotFound); + } + + /// + public async Task IsDeniedAtRootLevelAsync(IUser currentUser, ISet permissionsToCheck) + { + ElementAuthorizationStatus result = + await _elementPermissionService.AuthorizeRootAccessAsync(currentUser, permissionsToCheck); + + // If we can't find the element item(s) then we can't determine whether you are denied access. + return result is not (ElementAuthorizationStatus.Success or ElementAuthorizationStatus.NotFound); + } + + /// + public async Task IsDeniedAtRecycleBinLevelAsync(IUser currentUser, ISet permissionsToCheck) + { + ElementAuthorizationStatus result = + await _elementPermissionService.AuthorizeBinAccessAsync(currentUser, permissionsToCheck); + + // If we can't find the element item(s) then we can't determine whether you are denied access. + return result is not (ElementAuthorizationStatus.Success or ElementAuthorizationStatus.NotFound); + } + + /// + public async Task IsDeniedForCultures(IUser currentUser, ISet culturesToCheck) + { + ElementAuthorizationStatus result = + await _elementPermissionService.AuthorizeCultureAccessAsync(currentUser, culturesToCheck); + + // If we can't find the element item(s) then we can't determine whether you are denied access. + return result is not (ElementAuthorizationStatus.Success or ElementAuthorizationStatus.NotFound); + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Security/Authorization/ElementPermissionResource.cs b/src/Umbraco.Core/Security/Authorization/ElementPermissionResource.cs new file mode 100644 index 000000000000..a30d3e19bde8 --- /dev/null +++ b/src/Umbraco.Core/Security/Authorization/ElementPermissionResource.cs @@ -0,0 +1,250 @@ +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Security.Authorization; + +/// +/// A resource used for the . +/// +public class ElementPermissionResource : IPermissionResource +{ + /// + /// Creates a with the specified permission and element key or root. + /// + /// The permission to check for. + /// The key of the element or null if root. + /// An instance of . + public static ElementPermissionResource WithKeys(string permissionToCheck, Guid? elementKey) => + elementKey is null + ? Root(permissionToCheck) + : WithKeys(permissionToCheck, elementKey.Value.Yield()); + + /// + /// Creates a with the specified permission and element key or root. + /// + /// The permission to check for. + /// The key of the element or null if root. + /// The cultures to validate + /// An instance of . + public static ElementPermissionResource WithKeys( + string permissionToCheck, + Guid? elementKey, + IEnumerable cultures) => + elementKey is null + ? Root(permissionToCheck, cultures) + : WithKeys(permissionToCheck, elementKey.Value.Yield(), cultures); + + /// + /// Creates a with the specified permission and element keys. + /// + /// The permission to check for. + /// The keys of the elements or null if root. + /// An instance of . + public static ElementPermissionResource WithKeys(string permissionToCheck, IEnumerable elementKeys) + { + var hasRoot = elementKeys.Any(x => x is null); + IEnumerable keys = elementKeys.Where(x => x.HasValue).Select(x => x!.Value); + + return new ElementPermissionResource(keys, new HashSet { permissionToCheck }, hasRoot, false, null, null); + } + + /// + /// Creates a with the specified permission and element key. + /// + /// The permission to check for. + /// The key of the element. + /// An instance of . + public static ElementPermissionResource WithKeys(string permissionToCheck, Guid elementKey) => + WithKeys(permissionToCheck, elementKey.Yield()); + + /// + /// Creates a with the specified permission and element key. + /// + /// The permission to check for. + /// The key of the element. + /// The required culture access + /// An instance of . + public static ElementPermissionResource WithKeys( + string permissionToCheck, + Guid elementKey, + IEnumerable cultures) => WithKeys(permissionToCheck, elementKey.Yield(), cultures); + + /// + /// Creates a with the specified permission and element keys. + /// + /// The permission to check for. + /// The keys of the elements. + /// An instance of . + public static ElementPermissionResource WithKeys(string permissionToCheck, IEnumerable elementKeys) => + new(elementKeys, new HashSet { permissionToCheck }, false, false, null, null); + + /// + /// Creates a with the specified permission and element keys. + /// + /// The permission to check for. + /// The keys of the elements. + /// The required culture access + /// An instance of . + public static ElementPermissionResource WithKeys( + string permissionToCheck, + IEnumerable elementKeys, + IEnumerable cultures) => + new( + elementKeys, + new HashSet { permissionToCheck }, + false, + false, + null, + new HashSet(cultures.Distinct())); + + /// + /// Creates a with the specified permissions and element keys. + /// + /// The permissions to check for. + /// The keys of the elements. + /// An instance of . + public static ElementPermissionResource WithKeys(ISet permissionsToCheck, IEnumerable elementKeys) => + new(elementKeys, permissionsToCheck, false, false, null, null); + + /// + /// Creates a with the specified permission and the root. + /// + /// The permission to check for. + /// An instance of . + public static ElementPermissionResource Root(string permissionToCheck) => + new(Enumerable.Empty(), new HashSet { permissionToCheck }, true, false, null, null); + + /// + /// Creates a with the specified permission and the root. + /// + /// The permission to check for. + /// The cultures to validate + /// An instance of . + public static ElementPermissionResource Root(string permissionToCheck, IEnumerable cultures) => + new( + Enumerable.Empty(), + new HashSet { permissionToCheck }, + true, + false, + null, + new HashSet(cultures)); + + /// + /// Creates a with the specified permissions and the root. + /// + /// The permissions to check for. + /// An instance of . + public static ElementPermissionResource Root(ISet permissionsToCheck) => + new(Enumerable.Empty(), permissionsToCheck, true, false, null, null); + + /// + /// Creates a with the specified permissions and the root. + /// + /// The permissions to check for. + /// The cultures to validate + /// An instance of . + public static ElementPermissionResource Root(ISet permissionsToCheck, IEnumerable cultures) => + new(Enumerable.Empty(), permissionsToCheck, true, false, null, new HashSet(cultures)); + + + /// + /// Creates a with the specified permissions and the recycle bin. + /// + /// The permissions to check for. + /// An instance of . + public static ElementPermissionResource RecycleBin(ISet permissionsToCheck) => + new(Enumerable.Empty(), permissionsToCheck, false, true, null, null); + + /// + /// Creates a with the specified permission and the recycle bin. + /// + /// The permission to check for. + /// An instance of . + public static ElementPermissionResource RecycleBin(string permissionToCheck) => + new(Enumerable.Empty(), new HashSet { permissionToCheck }, false, true, null, null); + + /// + /// Creates a with the specified permissions and the branch from the specified parent key. + /// + /// The permissions to check for. + /// The parent key of the branch. + /// An instance of . + public static ElementPermissionResource Branch(ISet permissionsToCheck, Guid parentKeyForBranch) => + new(Enumerable.Empty(), permissionsToCheck, false, true, parentKeyForBranch, null); + + /// + /// Creates a with the specified permission and the branch from the specified parent key. + /// + /// The permission to check for. + /// The parent key of the branch. + /// An instance of . + public static ElementPermissionResource Branch(string permissionToCheck, Guid parentKeyForBranch) => + new(Enumerable.Empty(), new HashSet { permissionToCheck }, false, true, parentKeyForBranch, null); + + /// + /// Creates a with the specified permission and the branch from the specified parent key. + /// + /// The permission to check for. + /// The parent key of the branch. + /// The required cultures + /// An instance of . + public static ElementPermissionResource Branch( + string permissionToCheck, + Guid parentKeyForBranch, + IEnumerable culturesToCheck) => + new( + Enumerable.Empty(), + new HashSet { permissionToCheck }, + false, + true, + parentKeyForBranch, + new HashSet(culturesToCheck.Distinct())); + + private ElementPermissionResource( + IEnumerable elementKeys, + ISet permissionsToCheck, + bool checkRoot, + bool checkRecycleBin, + Guid? parentKeyForBranch, + ISet? culturesToCheck) + { + ElementKeys = elementKeys; + PermissionsToCheck = permissionsToCheck; + CheckRoot = checkRoot; + CheckRecycleBin = checkRecycleBin; + ParentKeyForBranch = parentKeyForBranch; + CulturesToCheck = culturesToCheck; + } + + /// + /// Gets the element keys. + /// + public IEnumerable ElementKeys { get; } + + /// + /// Gets the collection of permissions to authorize. + /// + /// + /// All permissions have to be satisfied when evaluating. + /// + public ISet PermissionsToCheck { get; } + + /// + /// Gets a value indicating whether to check for the root. + /// + public bool CheckRoot { get; } + + /// + /// Gets a value indicating whether to check for the recycle bin. + /// + public bool CheckRecycleBin { get; } + + /// + /// Gets the parent key of a branch. + /// + public Guid? ParentKeyForBranch { get; } + + /// + /// All the cultures need to be accessible when evaluating + /// + public ISet? CulturesToCheck { get; } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Security/Authorization/IElementPermissionAuthorizer.cs b/src/Umbraco.Core/Security/Authorization/IElementPermissionAuthorizer.cs new file mode 100644 index 000000000000..0f7b9fb8a029 --- /dev/null +++ b/src/Umbraco.Core/Security/Authorization/IElementPermissionAuthorizer.cs @@ -0,0 +1,90 @@ +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Security.Authorization; + +/// +/// Authorizes element access. +/// +public interface IElementPermissionAuthorizer +{ + /// + /// Authorizes whether the current user has access to the specified element item. + /// + /// The current user. + /// The key of the element item to check for. + /// The permission to authorize. + /// Returns true if authorization is denied, otherwise false. + Task IsDeniedAsync(IUser currentUser, Guid elementKey, string permissionToCheck) + => IsDeniedAsync(currentUser, elementKey.Yield(), new HashSet { permissionToCheck }); + + /// + /// Authorizes whether the current user has access to the specified element item(s). + /// + /// The current user. + /// The keys of the element items to check for. + /// The collection of permissions to authorize. + /// Returns true if authorization is denied, otherwise false. + Task IsDeniedAsync(IUser currentUser, IEnumerable elementKeys, ISet permissionsToCheck); + + /// + /// Authorizes whether the current user has access to the descendants of the specified element item. + /// + /// The current user. + /// The key of the parent element item. + /// The permission to authorize. + /// Returns true if authorization is denied, otherwise false. + Task IsDeniedWithDescendantsAsync(IUser currentUser, Guid parentKey, string permissionToCheck) + => IsDeniedWithDescendantsAsync(currentUser, parentKey, new HashSet { permissionToCheck }); + + /// + /// Authorizes whether the current user has access to the descendants of the specified element item. + /// + /// The current user. + /// The key of the parent element item. + /// The collection of permissions to authorize. + /// Returns true if authorization is denied, otherwise false. + Task IsDeniedWithDescendantsAsync(IUser currentUser, Guid parentKey, ISet permissionsToCheck); + + /// + /// Authorizes whether the current user has access to the root item. + /// + /// The current user. + /// The permission to authorize. + /// Returns true if authorization is denied, otherwise false. + Task IsDeniedAtRootLevelAsync(IUser currentUser, string permissionToCheck) + => IsDeniedAtRootLevelAsync(currentUser, new HashSet { permissionToCheck }); + + /// + /// Authorizes whether the current user has access to the root item. + /// + /// The current user. + /// The collection of permissions to authorize. + /// Returns true if authorization is denied, otherwise false. + Task IsDeniedAtRootLevelAsync(IUser currentUser, ISet permissionsToCheck); + + /// + /// Authorizes whether the current user has access to the recycle bin item. + /// + /// The current user. + /// The permission to authorize. + /// Returns true if authorization is denied, otherwise false. + Task IsDeniedAtRecycleBinLevelAsync(IUser currentUser, string permissionToCheck) + => IsDeniedAtRecycleBinLevelAsync(currentUser, new HashSet { permissionToCheck }); + + /// + /// Authorizes whether the current user has access to the recycle bin item. + /// + /// The current user. + /// The collection of permissions to authorize. + /// Returns true if authorization is denied, otherwise false. + Task IsDeniedAtRecycleBinLevelAsync(IUser currentUser, ISet permissionsToCheck); + + /// + /// Authorizes whether the current user has access to the specified cultures. + /// + /// The current user. + /// The cultures to check for access. + /// Returns true if authorization is denied, otherwise false. + Task IsDeniedForCultures(IUser currentUser, ISet culturesToCheck); +} \ No newline at end of file diff --git a/src/Umbraco.Core/Services/AuthorizationStatus/ElementAuthorizationStatus.cs b/src/Umbraco.Core/Services/AuthorizationStatus/ElementAuthorizationStatus.cs new file mode 100644 index 000000000000..c385e434e442 --- /dev/null +++ b/src/Umbraco.Core/Services/AuthorizationStatus/ElementAuthorizationStatus.cs @@ -0,0 +1,13 @@ +namespace Umbraco.Cms.Core.Services.AuthorizationStatus; + +public enum ElementAuthorizationStatus +{ + Success, + NotFound, + UnauthorizedMissingBinAccess, + UnauthorizedMissingDescendantAccess, + UnauthorizedMissingPathAccess, + UnauthorizedMissingRootAccess, + UnauthorizedMissingCulture, + UnauthorizedMissingPermissionAccess +} \ No newline at end of file diff --git a/src/Umbraco.Core/Services/ContentPublishingService.cs b/src/Umbraco.Core/Services/ContentPublishingService.cs index baba993ccf97..0d0c9ab01208 100644 --- a/src/Umbraco.Core/Services/ContentPublishingService.cs +++ b/src/Umbraco.Core/Services/ContentPublishingService.cs @@ -1,10 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Events; -using Umbraco.Cms.Core.Extensions; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.OperationStatus; @@ -13,22 +10,19 @@ namespace Umbraco.Cms.Core.Services; -internal sealed class ContentPublishingService : IContentPublishingService +internal sealed class ContentPublishingService : ContentPublishingServiceBase, IContentPublishingService { private const string PublishBranchOperationType = "ContentPublishBranch"; private readonly ICoreScopeProvider _coreScopeProvider; private readonly IContentService _contentService; private readonly IUserIdKeyResolver _userIdKeyResolver; - private readonly IContentValidationService _contentValidationService; - private readonly IContentTypeService _contentTypeService; - private readonly ILanguageService _languageService; - private ContentSettings _contentSettings; - private readonly IRelationService _relationService; private readonly ILogger _logger; private readonly ILongRunningOperationService _longRunningOperationService; private readonly IUmbracoContextFactory _umbracoContextFactory; + protected override int WriteLockId => Constants.Locks.ContentTree; + public ContentPublishingService( ICoreScopeProvider coreScopeProvider, IContentService contentService, @@ -41,235 +35,25 @@ public ContentPublishingService( ILogger logger, ILongRunningOperationService longRunningOperationService, IUmbracoContextFactory umbracoContextFactory) + : base( + coreScopeProvider, + contentService, + userIdKeyResolver, + contentValidationService, + contentTypeService, + languageService, + optionsMonitor, + relationService, + logger) { _coreScopeProvider = coreScopeProvider; _contentService = contentService; _userIdKeyResolver = userIdKeyResolver; - _contentValidationService = contentValidationService; - _contentTypeService = contentTypeService; - _languageService = languageService; - _relationService = relationService; _logger = logger; _longRunningOperationService = longRunningOperationService; - _contentSettings = optionsMonitor.CurrentValue; - optionsMonitor.OnChange((contentSettings) => - { - _contentSettings = contentSettings; - }); _umbracoContextFactory = umbracoContextFactory; } - /// - public async Task> PublishAsync( - Guid key, - ICollection culturesToPublishOrSchedule, - Guid userKey) - { - var culturesToPublishImmediately = - culturesToPublishOrSchedule.Where(culture => culture.Schedule is null).Select(c => c.Culture ?? Constants.System.InvariantCulture).ToHashSet(); - - ContentScheduleCollection schedules = _contentService.GetContentScheduleByContentId(key); - - foreach (CulturePublishScheduleModel cultureToSchedule in culturesToPublishOrSchedule.Where(c => c.Schedule is not null)) - { - var culture = cultureToSchedule.Culture ?? Constants.System.InvariantCulture; - - if (cultureToSchedule.Schedule!.PublishDate is null) - { - schedules.RemoveIfExists(culture, ContentScheduleAction.Release); - } - else - { - schedules.AddOrUpdate(culture, cultureToSchedule.Schedule!.PublishDate.Value.UtcDateTime, ContentScheduleAction.Release); - } - - if (cultureToSchedule.Schedule!.UnpublishDate is null) - { - schedules.RemoveIfExists(culture, ContentScheduleAction.Expire); - } - else - { - schedules.AddOrUpdate(culture, cultureToSchedule.Schedule!.UnpublishDate.Value.UtcDateTime, ContentScheduleAction.Expire); - } - } - - var cultureAndSchedule = new CultureAndScheduleModel - { - CulturesToPublishImmediately = culturesToPublishImmediately, - Schedules = schedules, - }; - - return await PublishAsync(key, cultureAndSchedule, userKey); - } - - /// - // TODO - Integrate this implementation into the one above. - private async Task> PublishAsync( - Guid key, - CultureAndScheduleModel cultureAndSchedule, - Guid userKey) - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - scope.WriteLock(Constants.Locks.ContentTree); - IContent? content = _contentService.GetById(key); - if (content is null) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentNotFound, new ContentPublishingResult()); - } - - // If nothing is requested for publish or scheduling, clear all schedules and publish nothing. - if (cultureAndSchedule.CulturesToPublishImmediately.Count == 0 && - cultureAndSchedule.Schedules.FullSchedule.Count == 0) - { - _contentService.PersistContentSchedule(content, cultureAndSchedule.Schedules); - scope.Complete(); - return Attempt.SucceedWithStatus( - ContentPublishingOperationStatus.Success, - new ContentPublishingResult { Content = content }); - } - - ISet culturesToPublishImmediately = cultureAndSchedule.CulturesToPublishImmediately; - - var cultures = - culturesToPublishImmediately.Union( - cultureAndSchedule.Schedules.FullSchedule.Select(x => x.Culture)).ToArray(); - - // If cultures are provided for non variant content, and they include the default culture, consider - // the request as valid for publishing the content. - // This is necessary as in a bulk publishing context the cultures are selected and provided from the - // list of languages. - bool variesByCulture = content.ContentType.VariesByCulture(); - if (!variesByCulture) - { - ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); - if (defaultLanguage is not null) - { - if (cultures.Contains(defaultLanguage.IsoCode)) - { - cultures = ["*"]; - } - - if (culturesToPublishImmediately.Contains(defaultLanguage.IsoCode)) - { - culturesToPublishImmediately = new HashSet { "*" }; - } - } - } - - if (variesByCulture) - { - if (cultures.Any() is false) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.CultureMissing, new ContentPublishingResult()); - } - - if (cultures.Any(x => x == Constants.System.InvariantCulture)) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant, new ContentPublishingResult()); - } - - IEnumerable validCultures = await _languageService.GetAllIsoCodesAsync(); - if (validCultures.ContainsAll(cultures) is false) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.InvalidCulture, new ContentPublishingResult()); - } - } - else - { - if (cultures.Length != 1 || cultures.Any(x => x != Constants.System.InvariantCulture)) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.InvalidCulture, new ContentPublishingResult()); - } - } - - ContentValidationResult validationResult = await ValidateCurrentContentAsync(content, cultures); - if (validationResult.ValidationErrors.Any()) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentInvalid, new ContentPublishingResult - { - Content = content, - InvalidPropertyAliases = validationResult.ValidationErrors.Select(property => property.Alias).ToArray() - }); - } - - - var userId = await _userIdKeyResolver.GetAsync(userKey); - - PublishResult? result = null; - if (culturesToPublishImmediately.Any()) - { - result = _contentService.Publish(content, culturesToPublishImmediately.ToArray(), userId); - } - - if (result?.Success != false && cultureAndSchedule.Schedules.FullSchedule.Any()) - { - _contentService.PersistContentSchedule(result?.Content ?? content, cultureAndSchedule.Schedules); - result = new PublishResult( - PublishResultType.SuccessPublish, - result?.EventMessages ?? new EventMessages(), - result?.Content ?? content); - } - - scope.Complete(); - - if (result is null) - { - return Attempt.FailWithStatus(ContentPublishingOperationStatus.NothingToPublish, new ContentPublishingResult()); - } - - ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); - return contentPublishingOperationStatus is ContentPublishingOperationStatus.Success - ? Attempt.SucceedWithStatus( - ToContentPublishingOperationStatus(result), - new ContentPublishingResult { Content = content }) - : Attempt.FailWithStatus(ToContentPublishingOperationStatus(result), new ContentPublishingResult - { - Content = content, - InvalidPropertyAliases = result.InvalidProperties?.Select(property => property.Alias).ToArray() - ?? Enumerable.Empty() - }); - } - - private async Task ValidateCurrentContentAsync(IContent content, string[] cultures) - { - IEnumerable effectiveCultures = content.ContentType.VariesByCulture() - ? cultures.Union([null]) - : [null]; - - // Would be better to be able to use a mapper/factory, but currently all that functionality is very much presentation logic. - var model = new ContentUpdateModel() - { - // NOTE KJA: this needs redoing; we need to make an informed decision whether to include invariant properties, depending on if editing invariant properties is allowed on all variants, or if the default language is included in cultures - Properties = effectiveCultures.SelectMany(culture => - content.Properties.Select(property => property.PropertyType.VariesByCulture() == (culture is not null) - ? new PropertyValueModel - { - Alias = property.Alias, - Value = property.GetValue(culture: culture, segment: null, published: false), - Culture = culture - } - : null) - .WhereNotNull()) - .ToArray(), - Variants = cultures.Select(culture => new VariantModel() - { - Name = content.GetPublishName(culture) ?? string.Empty, - Culture = culture, - Segment = null - }).ToArray() - }; - - IContentType? contentType = _contentTypeService.Get(content.ContentType.Key)!; - ContentValidationResult validationResult = await _contentValidationService.ValidatePropertiesAsync(model, contentType, cultures); - return validationResult; - } - /// public async Task> PublishBranchAsync( Guid key, @@ -394,173 +178,6 @@ await _longRunningOperationService return MapInternalPublishingAttempt(result.Result); } - /// - public async Task> UnpublishAsync(Guid key, ISet? cultures, Guid userKey) - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - IContent? content = _contentService.GetById(key); - if (content is null) - { - scope.Complete(); - return Attempt.Fail(ContentPublishingOperationStatus.ContentNotFound); - } - - if (_contentSettings.DisableUnpublishWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) - { - scope.Complete(); - return Attempt.Fail(ContentPublishingOperationStatus.CannotUnpublishWhenReferenced); - } - - var userId = await _userIdKeyResolver.GetAsync(userKey); - - // If cultures are provided for non variant content, and they include the default culture, consider - // the request as valid for unpublishing the content. - // This is necessary as in a bulk unpublishing context the cultures are selected and provided from the - // list of languages. - if (cultures is not null && !content.ContentType.VariesByCulture()) - { - ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); - if (defaultLanguage is not null && cultures.Contains(defaultLanguage.IsoCode)) - { - cultures = null; - } - } - - Attempt attempt; - if (cultures is null) - { - attempt = await UnpublishInvariantAsync( - content, - userId); - - scope.Complete(); - return attempt; - } - - if (cultures.Any() is false) - { - scope.Complete(); - return Attempt.Fail(ContentPublishingOperationStatus.CultureMissing); - } - - if (cultures.Contains("*")) - { - attempt = await UnpublishAllCulturesAsync( - content, - userId); - } - else - { - attempt = await UnpublishMultipleCultures( - content, - cultures, - userId); - } - scope.Complete(); - - return attempt; - } - - private Task> UnpublishAllCulturesAsync(IContent content, int userId) - { - if (content.ContentType.VariesByCulture() is false) - { - return Task.FromResult(Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant)); - } - - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - PublishResult result = _contentService.Unpublish(content, "*", userId); - scope.Complete(); - - ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); - return Task.FromResult(contentPublishingOperationStatus is ContentPublishingOperationStatus.Success - ? Attempt.Succeed(ToContentPublishingOperationStatus(result)) - : Attempt.Fail(ToContentPublishingOperationStatus(result))); - } - - private async Task> UnpublishMultipleCultures(IContent content, ISet cultures, int userId) - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - - if (content.ContentType.VariesByCulture() is false) - { - scope.Complete(); - return Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant); - } - - var validCultures = (await _languageService.GetAllIsoCodesAsync()).ToArray(); - - foreach (var culture in cultures) - { - if (validCultures.Contains(culture) is false) - { - scope.Complete(); - return Attempt.Fail(ContentPublishingOperationStatus.InvalidCulture); - } - - PublishResult result = _contentService.Unpublish(content, culture, userId); - - ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); - - if (contentPublishingOperationStatus is not ContentPublishingOperationStatus.Success) - { - return Attempt.Fail(ToContentPublishingOperationStatus(result)); - } - } - - scope.Complete(); - return Attempt.Succeed(ContentPublishingOperationStatus.Success); - } - - - private Task> UnpublishInvariantAsync(IContent content, int userId) - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - - if (content.ContentType.VariesByCulture()) - { - return Task.FromResult(Attempt.Fail(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant)); - } - - PublishResult result = _contentService.Unpublish(content, null, userId); - scope.Complete(); - - ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); - return Task.FromResult(contentPublishingOperationStatus is ContentPublishingOperationStatus.Success - ? Attempt.Succeed(ToContentPublishingOperationStatus(result)) - : Attempt.Fail(ToContentPublishingOperationStatus(result))); - } - - private static ContentPublishingOperationStatus ToContentPublishingOperationStatus(PublishResult publishResult) - => publishResult.Result switch - { - PublishResultType.SuccessPublish => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessPublishCulture => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessPublishAlready => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessUnpublish => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessUnpublishAlready => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessUnpublishCulture => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessUnpublishMandatoryCulture => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessUnpublishLastCulture => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessMixedCulture => ContentPublishingOperationStatus.Success, - // PublishResultType.FailedPublish => expr, <-- never used directly in a PublishResult - PublishResultType.FailedPublishPathNotPublished => ContentPublishingOperationStatus.PathNotPublished, - PublishResultType.FailedPublishHasExpired => ContentPublishingOperationStatus.HasExpired, - PublishResultType.FailedPublishAwaitingRelease => ContentPublishingOperationStatus.AwaitingRelease, - PublishResultType.FailedPublishCultureHasExpired => ContentPublishingOperationStatus.CultureHasExpired, - PublishResultType.FailedPublishCultureAwaitingRelease => ContentPublishingOperationStatus.CultureAwaitingRelease, - PublishResultType.FailedPublishIsTrashed => ContentPublishingOperationStatus.InTrash, - PublishResultType.FailedPublishCancelledByEvent => ContentPublishingOperationStatus.CancelledByEvent, - PublishResultType.FailedPublishContentInvalid => ContentPublishingOperationStatus.ContentInvalid, - PublishResultType.FailedPublishNothingToPublish => ContentPublishingOperationStatus.NothingToPublish, - PublishResultType.FailedPublishMandatoryCultureMissing => ContentPublishingOperationStatus.MandatoryCultureMissing, - PublishResultType.FailedPublishConcurrencyViolation => ContentPublishingOperationStatus.ConcurrencyViolation, - PublishResultType.FailedPublishUnsavedChanges => ContentPublishingOperationStatus.UnsavedChanges, - PublishResultType.FailedUnpublish => ContentPublishingOperationStatus.Failed, - PublishResultType.FailedUnpublishCancelledByEvent => ContentPublishingOperationStatus.CancelledByEvent, - _ => throw new ArgumentOutOfRangeException() - }; - private Attempt MapInternalPublishingAttempt( Attempt minimalAttempt) => minimalAttempt.Success diff --git a/src/Umbraco.Core/Services/ContentPublishingServiceBase.cs b/src/Umbraco.Core/Services/ContentPublishingServiceBase.cs new file mode 100644 index 000000000000..269c7db4aad0 --- /dev/null +++ b/src/Umbraco.Core/Services/ContentPublishingServiceBase.cs @@ -0,0 +1,433 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Extensions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +internal abstract class ContentPublishingServiceBase + where TContent : class, IPublishableContentBase + where TContentService : IPublishableContentService +{ + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly TContentService _contentService; + private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly IContentValidationService _contentValidationService; + private readonly IContentTypeService _contentTypeService; + private readonly ILanguageService _languageService; + private ContentSettings _contentSettings; + private readonly IRelationService _relationService; + private readonly ILogger> _logger; + + protected abstract int WriteLockId { get; } + + protected ContentPublishingServiceBase( + ICoreScopeProvider coreScopeProvider, + TContentService contentService, + IUserIdKeyResolver userIdKeyResolver, + IContentValidationService contentValidationService, + IContentTypeService contentTypeService, + ILanguageService languageService, + IOptionsMonitor optionsMonitor, + IRelationService relationService, + ILogger> logger) + { + _coreScopeProvider = coreScopeProvider; + _contentService = contentService; + _userIdKeyResolver = userIdKeyResolver; + _contentValidationService = contentValidationService; + _contentTypeService = contentTypeService; + _languageService = languageService; + _relationService = relationService; + _logger = logger; + _contentSettings = optionsMonitor.CurrentValue; + optionsMonitor.OnChange((contentSettings) => + { + _contentSettings = contentSettings; + }); + } + + /// + public async Task> PublishAsync( + Guid key, + ICollection culturesToPublishOrSchedule, + Guid userKey) + { + var culturesToPublishImmediately = + culturesToPublishOrSchedule.Where(culture => culture.Schedule is null).Select(c => c.Culture ?? Constants.System.InvariantCulture).ToHashSet(); + + ContentScheduleCollection schedules = _contentService.GetContentScheduleByContentId(key); + + foreach (CulturePublishScheduleModel cultureToSchedule in culturesToPublishOrSchedule.Where(c => c.Schedule is not null)) + { + var culture = cultureToSchedule.Culture ?? Constants.System.InvariantCulture; + + if (cultureToSchedule.Schedule!.PublishDate is null) + { + schedules.RemoveIfExists(culture, ContentScheduleAction.Release); + } + else + { + schedules.AddOrUpdate(culture, cultureToSchedule.Schedule!.PublishDate.Value.UtcDateTime, ContentScheduleAction.Release); + } + + if (cultureToSchedule.Schedule!.UnpublishDate is null) + { + schedules.RemoveIfExists(culture, ContentScheduleAction.Expire); + } + else + { + schedules.AddOrUpdate(culture, cultureToSchedule.Schedule!.UnpublishDate.Value.UtcDateTime, ContentScheduleAction.Expire); + } + } + + var cultureAndSchedule = new CultureAndScheduleModel + { + CulturesToPublishImmediately = culturesToPublishImmediately, + Schedules = schedules, + }; + + return await PublishAsync(key, cultureAndSchedule, userKey); + } + + // TODO - Integrate this implementation into the one above. + [Obsolete("Use non obsoleted version instead. Scheduled for removal in v17")] + private async Task> PublishAsync( + Guid key, + CultureAndScheduleModel cultureAndSchedule, + Guid userKey) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.WriteLock(WriteLockId); + TContent? content = _contentService.GetById(key); + if (content is null) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentNotFound, new ContentPublishingResult()); + } + + // If nothing is requested for publish or scheduling, clear all schedules and publish nothing. + if (cultureAndSchedule.CulturesToPublishImmediately.Count == 0 && + cultureAndSchedule.Schedules.FullSchedule.Count == 0) + { + _contentService.PersistContentSchedule(content, cultureAndSchedule.Schedules); + scope.Complete(); + return Attempt.SucceedWithStatus( + ContentPublishingOperationStatus.Success, + new ContentPublishingResult { Content = content }); + } + + ISet culturesToPublishImmediately = cultureAndSchedule.CulturesToPublishImmediately; + + var cultures = + culturesToPublishImmediately.Union( + cultureAndSchedule.Schedules.FullSchedule.Select(x => x.Culture)).ToArray(); + + // If cultures are provided for non variant content, and they include the default culture, consider + // the request as valid for publishing the content. + // This is necessary as in a bulk publishing context the cultures are selected and provided from the + // list of languages. + bool variesByCulture = content.ContentType.VariesByCulture(); + if (!variesByCulture) + { + ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); + if (defaultLanguage is not null) + { + if (cultures.Contains(defaultLanguage.IsoCode)) + { + cultures = ["*"]; + } + + if (culturesToPublishImmediately.Contains(defaultLanguage.IsoCode)) + { + culturesToPublishImmediately = new HashSet { "*" }; + } + } + } + + if (variesByCulture) + { + if (cultures.Any() is false) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.CultureMissing, new ContentPublishingResult()); + } + + if (cultures.Any(x => x == Constants.System.InvariantCulture)) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant, new ContentPublishingResult()); + } + + IEnumerable validCultures = await _languageService.GetAllIsoCodesAsync(); + if (validCultures.ContainsAll(cultures) is false) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.InvalidCulture, new ContentPublishingResult()); + } + } + else + { + if (cultures.Length != 1 || cultures.Any(x => x != Constants.System.InvariantCulture)) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.InvalidCulture, new ContentPublishingResult()); + } + } + + ContentValidationResult validationResult = await ValidateCurrentContentAsync(content, cultures); + if (validationResult.ValidationErrors.Any()) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentInvalid, new ContentPublishingResult + { + Content = content, + InvalidPropertyAliases = validationResult.ValidationErrors.Select(property => property.Alias).ToArray() + }); + } + + + var userId = await _userIdKeyResolver.GetAsync(userKey); + + PublishResult? result = null; + if (culturesToPublishImmediately.Any()) + { + result = _contentService.Publish(content, culturesToPublishImmediately.ToArray(), userId); + } + + if (result?.Success != false && cultureAndSchedule.Schedules.FullSchedule.Any()) + { + _contentService.PersistContentSchedule(result?.Content ?? content, cultureAndSchedule.Schedules); + result = new PublishResult( + PublishResultType.SuccessPublish, + result?.EventMessages ?? new EventMessages(), + result?.Content ?? content); + } + + scope.Complete(); + + if (result is null) + { + return Attempt.FailWithStatus(ContentPublishingOperationStatus.NothingToPublish, new ContentPublishingResult()); + } + + ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); + return contentPublishingOperationStatus is ContentPublishingOperationStatus.Success + ? Attempt.SucceedWithStatus( + ToContentPublishingOperationStatus(result), + new ContentPublishingResult { Content = content }) + : Attempt.FailWithStatus(ToContentPublishingOperationStatus(result), new ContentPublishingResult + { + Content = content, + InvalidPropertyAliases = result.InvalidProperties?.Select(property => property.Alias).ToArray() + ?? Enumerable.Empty() + }); + } + + private async Task ValidateCurrentContentAsync(TContent content, string[] cultures) + { + IEnumerable effectiveCultures = content.ContentType.VariesByCulture() + ? cultures.Union([null]) + : [null]; + + // Would be better to be able to use a mapper/factory, but currently all that functionality is very much presentation logic. + var model = new ContentUpdateModel() + { + // NOTE KJA: this needs redoing; we need to make an informed decision whether to include invariant properties, depending on if editing invariant properties is allowed on all variants, or if the default language is included in cultures + Properties = effectiveCultures.SelectMany(culture => + content.Properties.Select(property => property.PropertyType.VariesByCulture() == (culture is not null) + ? new PropertyValueModel + { + Alias = property.Alias, + Value = property.GetValue(culture: culture, segment: null, published: false), + Culture = culture + } + : null) + .WhereNotNull()) + .ToArray(), + Variants = cultures.Select(culture => new VariantModel() + { + Name = content.GetPublishName(culture) ?? string.Empty, + Culture = culture, + Segment = null + }).ToArray() + }; + + IContentType? contentType = _contentTypeService.Get(content.ContentType.Key)!; + ContentValidationResult validationResult = await _contentValidationService.ValidatePropertiesAsync(model, contentType, cultures); + return validationResult; + } + + /// + public async Task> UnpublishAsync(Guid key, ISet? cultures, Guid userKey) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + TContent? content = _contentService.GetById(key); + if (content is null) + { + scope.Complete(); + return Attempt.Fail(ContentPublishingOperationStatus.ContentNotFound); + } + + if (_contentSettings.DisableUnpublishWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) + { + scope.Complete(); + return Attempt.Fail(ContentPublishingOperationStatus.CannotUnpublishWhenReferenced); + } + + var userId = await _userIdKeyResolver.GetAsync(userKey); + + // If cultures are provided for non variant content, and they include the default culture, consider + // the request as valid for unpublishing the content. + // This is necessary as in a bulk unpublishing context the cultures are selected and provided from the + // list of languages. + if (cultures is not null && !content.ContentType.VariesByCulture()) + { + ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); + if (defaultLanguage is not null && cultures.Contains(defaultLanguage.IsoCode)) + { + cultures = null; + } + } + + Attempt attempt; + if (cultures is null) + { + attempt = await UnpublishInvariantAsync( + content, + userId); + + scope.Complete(); + return attempt; + } + + if (cultures.Any() is false) + { + scope.Complete(); + return Attempt.Fail(ContentPublishingOperationStatus.CultureMissing); + } + + if (cultures.Contains("*")) + { + attempt = await UnpublishAllCulturesAsync( + content, + userId); + } + else + { + attempt = await UnpublishMultipleCultures( + content, + cultures, + userId); + } + scope.Complete(); + + return attempt; + } + + private Task> UnpublishAllCulturesAsync(TContent content, int userId) + { + if (content.ContentType.VariesByCulture() is false) + { + return Task.FromResult(Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant)); + } + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + PublishResult result = _contentService.Unpublish(content, "*", userId); + scope.Complete(); + + ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); + return Task.FromResult(contentPublishingOperationStatus is ContentPublishingOperationStatus.Success + ? Attempt.Succeed(ToContentPublishingOperationStatus(result)) + : Attempt.Fail(ToContentPublishingOperationStatus(result))); + } + + private async Task> UnpublishMultipleCultures(TContent content, ISet cultures, int userId) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + + if (content.ContentType.VariesByCulture() is false) + { + scope.Complete(); + return Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant); + } + + var validCultures = (await _languageService.GetAllIsoCodesAsync()).ToArray(); + + foreach (var culture in cultures) + { + if (validCultures.Contains(culture) is false) + { + scope.Complete(); + return Attempt.Fail(ContentPublishingOperationStatus.InvalidCulture); + } + + PublishResult result = _contentService.Unpublish(content, culture, userId); + + ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); + + if (contentPublishingOperationStatus is not ContentPublishingOperationStatus.Success) + { + return Attempt.Fail(ToContentPublishingOperationStatus(result)); + } + } + + scope.Complete(); + return Attempt.Succeed(ContentPublishingOperationStatus.Success); + } + + private Task> UnpublishInvariantAsync(TContent content, int userId) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + + if (content.ContentType.VariesByCulture()) + { + return Task.FromResult(Attempt.Fail(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant)); + } + + PublishResult result = _contentService.Unpublish(content, null, userId); + scope.Complete(); + + ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); + return Task.FromResult(contentPublishingOperationStatus is ContentPublishingOperationStatus.Success + ? Attempt.Succeed(ToContentPublishingOperationStatus(result)) + : Attempt.Fail(ToContentPublishingOperationStatus(result))); + } + + protected static ContentPublishingOperationStatus ToContentPublishingOperationStatus(PublishResult publishResult) + => publishResult.Result switch + { + PublishResultType.SuccessPublish => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessPublishCulture => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessPublishAlready => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessUnpublish => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessUnpublishAlready => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessUnpublishCulture => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessUnpublishMandatoryCulture => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessUnpublishLastCulture => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessMixedCulture => ContentPublishingOperationStatus.Success, + // PublishResultType.FailedPublish => expr, <-- never used directly in a PublishResult + PublishResultType.FailedPublishPathNotPublished => ContentPublishingOperationStatus.PathNotPublished, + PublishResultType.FailedPublishHasExpired => ContentPublishingOperationStatus.HasExpired, + PublishResultType.FailedPublishAwaitingRelease => ContentPublishingOperationStatus.AwaitingRelease, + PublishResultType.FailedPublishCultureHasExpired => ContentPublishingOperationStatus.CultureHasExpired, + PublishResultType.FailedPublishCultureAwaitingRelease => ContentPublishingOperationStatus.CultureAwaitingRelease, + PublishResultType.FailedPublishIsTrashed => ContentPublishingOperationStatus.InTrash, + PublishResultType.FailedPublishCancelledByEvent => ContentPublishingOperationStatus.CancelledByEvent, + PublishResultType.FailedPublishContentInvalid => ContentPublishingOperationStatus.ContentInvalid, + PublishResultType.FailedPublishNothingToPublish => ContentPublishingOperationStatus.NothingToPublish, + PublishResultType.FailedPublishMandatoryCultureMissing => ContentPublishingOperationStatus.MandatoryCultureMissing, + PublishResultType.FailedPublishConcurrencyViolation => ContentPublishingOperationStatus.ConcurrencyViolation, + PublishResultType.FailedPublishUnsavedChanges => ContentPublishingOperationStatus.UnsavedChanges, + PublishResultType.FailedUnpublish => ContentPublishingOperationStatus.Failed, + PublishResultType.FailedUnpublishCancelledByEvent => ContentPublishingOperationStatus.CancelledByEvent, + _ => throw new ArgumentOutOfRangeException() + }; +} diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 0877a6900fa0..dd2f3be17d9b 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -7,7 +7,6 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; -using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Membership; @@ -26,10 +25,8 @@ namespace Umbraco.Cms.Core.Services; /// /// Implements the content service. /// -public class ContentService : RepositoryService, IContentService +public class ContentService : PublishableContentServiceBase, IContentService { - private readonly IAuditService _auditService; - private readonly IContentTypeRepository _contentTypeRepository; private readonly IDocumentBlueprintRepository _documentBlueprintRepository; private readonly IDocumentRepository _documentRepository; private readonly IEntityRepository _entityRepository; @@ -65,12 +62,22 @@ public ContentService( IIdKeyMap idKeyMap, IOptionsMonitor optionsMonitor, IRelationService relationService) - : base(provider, loggerFactory, eventMessagesFactory) + : base( + provider, + loggerFactory, + eventMessagesFactory, + auditService, + contentTypeRepository, + documentRepository, + languageRepository, + propertyValidationService, + cultureImpactFactory, + userIdKeyResolver, + propertyEditorCollection, + idKeyMap) { _documentRepository = documentRepository; _entityRepository = entityRepository; - _auditService = auditService; - _contentTypeRepository = contentTypeRepository; _documentBlueprintRepository = documentBlueprintRepository; _languageRepository = languageRepository; _propertyValidationService = propertyValidationService; @@ -179,107 +186,6 @@ public ContentService( #endregion - #region Rollback - - public OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId) - { - EventMessages evtMsgs = EventMessagesFactory.Get(); - - // Get the current copy of the node - IContent? content = GetById(id); - - // Get the version - IContent? version = GetVersion(versionId); - - // Good old null checks - if (content == null || version == null || content.Trashed) - { - return new OperationResult(OperationResultType.FailedCannot, evtMsgs); - } - - // Store the result of doing the save of content for the rollback - OperationResult rollbackSaveResult; - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - var rollingBackNotification = new ContentRollingBackNotification(content, evtMsgs); - if (scope.Notifications.PublishCancelable(rollingBackNotification)) - { - scope.Complete(); - return OperationResult.Cancel(evtMsgs); - } - - // Copy the changes from the version - content.CopyFrom(version, culture); - - // Save the content for the rollback - rollbackSaveResult = Save(content, userId); - - // Depending on the save result - is what we log & audit along with what we return - if (rollbackSaveResult.Success == false) - { - // Log the error/warning - _logger.LogError( - "User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, id, versionId); - } - else - { - scope.Notifications.Publish( - new ContentRolledBackNotification(content, evtMsgs).WithStateFrom(rollingBackNotification)); - - // Logging & Audit message - _logger.LogInformation("User '{UserId}' rolled back content '{ContentId}' to version '{VersionId}'", userId, id, versionId); - Audit(AuditType.RollBack, userId, id, $"Content '{content.Name}' was rolled back to version '{versionId}'"); - } - - scope.Complete(); - } - - return rollbackSaveResult; - } - - #endregion - - #region Count - - public int CountPublished(string? contentTypeAlias = null) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.CountPublished(contentTypeAlias); - } - } - - public int Count(string? contentTypeAlias = null) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.Count(contentTypeAlias); - } - } - - public int CountChildren(int parentId, string? contentTypeAlias = null) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.CountChildren(parentId, contentTypeAlias); - } - } - - public int CountDescendants(int parentId, string? contentTypeAlias = null) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.CountDescendants(parentId, contentTypeAlias); - } - } - - #endregion - #region Permissions /// @@ -517,94 +423,6 @@ public IContent CreateAndSave(string name, IContent parent, string contentTypeAl #region Get, Has, Is - /// - /// Gets an object by Id - /// - /// Id of the Content to retrieve - /// - /// - /// - public IContent? GetById(int id) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.Get(id); - } - } - - /// - /// Gets an object by Id - /// - /// Ids of the Content to retrieve - /// - /// - /// - public IEnumerable GetByIds(IEnumerable ids) - { - var idsA = ids.ToArray(); - if (idsA.Length == 0) - { - return Enumerable.Empty(); - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - IEnumerable items = _documentRepository.GetMany(idsA); - var index = items.ToDictionary(x => x.Id, x => x); - return idsA.Select(x => index.GetValueOrDefault(x)).WhereNotNull(); - } - } - - /// - /// Gets an object by its 'UniqueId' - /// - /// Guid key of the Content to retrieve - /// - /// - /// - public IContent? GetById(Guid key) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.Get(key); - } - } - - /// - public ContentScheduleCollection GetContentScheduleByContentId(int contentId) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentSchedule(contentId); - } - } - - public ContentScheduleCollection GetContentScheduleByContentId(Guid contentId) - { - Attempt idAttempt = _idKeyMap.GetIdForKey(contentId, UmbracoObjectTypes.Document); - if (idAttempt.Success is false) - { - return new ContentScheduleCollection(); - } - - return GetContentScheduleByContentId(idAttempt.Result); - } - - /// - public void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - _documentRepository.PersistContentSchedule(content, contentSchedule); - scope.Complete(); - } - } - /// /// /// @@ -613,105 +431,6 @@ public void PersistContentSchedule(IContent content, ContentScheduleCollection c Attempt IContentServiceBase.Save(IEnumerable contents, int userId) => Attempt.Succeed(Save(contents, userId)); - /// - /// Gets objects by Ids - /// - /// Ids of the Content to retrieve - /// - /// - /// - public IEnumerable GetByIds(IEnumerable ids) - { - Guid[] idsA = ids.ToArray(); - if (idsA.Length == 0) - { - return Enumerable.Empty(); - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - IEnumerable? items = _documentRepository.GetMany(idsA); - - if (items is not null) - { - var index = items.ToDictionary(x => x.Key, x => x); - - return idsA.Select(x => index.GetValueOrDefault(x)).WhereNotNull(); - } - - return Enumerable.Empty(); - } - } - - /// - public IEnumerable GetPagedOfType( - int contentTypeId, - long pageIndex, - int pageSize, - out long totalRecords, - IQuery? filter = null, - Ordering? ordering = null) - { - if (pageIndex < 0) - { - throw new ArgumentOutOfRangeException(nameof(pageIndex)); - } - - if (pageSize <= 0) - { - throw new ArgumentOutOfRangeException(nameof(pageSize)); - } - - ordering ??= Ordering.By("sortOrder"); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetPage( - Query()?.Where(x => x.ContentTypeId == contentTypeId), - pageIndex, - pageSize, - out totalRecords, - null, - filter, - ordering); - } - } - - /// - public IEnumerable GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery? filter, Ordering? ordering = null) - { - if (pageIndex < 0) - { - throw new ArgumentOutOfRangeException(nameof(pageIndex)); - } - - if (pageSize <= 0) - { - throw new ArgumentOutOfRangeException(nameof(pageSize)); - } - - ordering ??= Ordering.By("sortOrder"); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - // Need to use a List here because the expression tree cannot convert the array when used in Contains. - // See ExpressionTests.Sql_In(). - List contentTypeIdsAsList = [.. contentTypeIds]; - - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetPage( - Query()?.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId)), - pageIndex, - pageSize, - out totalRecords, - null, - filter, - ordering); - } - } - /// /// Gets a collection of objects by Level /// @@ -728,61 +447,6 @@ public IEnumerable GetByLevel(int level) } } - /// - /// Gets a specific version of an item. - /// - /// Id of the version to retrieve - /// An item - public IContent? GetVersion(int versionId) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetVersion(versionId); - } - } - - /// - /// Gets a collection of an objects versions by Id - /// - /// - /// An Enumerable list of objects - public IEnumerable GetVersions(int id) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetAllVersions(id); - } - } - - /// - /// Gets a collection of an objects versions by Id - /// - /// An Enumerable list of objects - public IEnumerable GetVersionsSlim(int id, int skip, int take) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetAllVersionsSlim(id, skip, take); - } - } - - /// - /// Gets a list of all version Ids for the given content item ordered so latest is first - /// - /// - /// The maximum number of rows to return - /// - public IEnumerable GetVersionIds(int id, int maxRows) - { - using (ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _documentRepository.GetVersionIds(id, maxRows); - } - } - /// /// Gets a collection of objects, which are ancestors of the current content. /// @@ -940,22 +604,6 @@ private IEnumerable GetPagedLocked(IQuery? query, long pageI return GetParent(content); } - /// - /// Gets the parent of the current content as an item. - /// - /// to retrieve the parent from - /// Parent object - public IContent? GetParent(IContent? content) - { - if (content?.ParentId == Constants.System.Root || content?.ParentId == Constants.System.RecycleBinContent || - content is null) - { - return null; - } - - return GetById(content.ParentId); - } - /// /// Gets a collection of objects, which reside at the first level / root /// @@ -983,1019 +631,122 @@ internal IEnumerable GetAllPublished() } } - /// - public IEnumerable GetContentForExpiration(DateTime date) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentForExpiration(date); - } - } - - /// - public IEnumerable GetContentForRelease(DateTime date) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentForRelease(date); - } - } - - /// - /// Gets a collection of an objects, which resides in the Recycle Bin - /// - /// An Enumerable list of objects - public IEnumerable GetPagedContentInRecycleBin(long pageIndex, int pageSize, out long totalRecords, IQuery? filter = null, Ordering? ordering = null) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - ordering ??= Ordering.By("Path"); - - scope.ReadLock(Constants.Locks.ContentTree); - IQuery? query = Query()? - .Where(x => x.Path.StartsWith(Constants.System.RecycleBinContentPathPrefix)); - return _documentRepository.GetPage(query, pageIndex, pageSize, out totalRecords, propertyAliases: null, filter, ordering); - } - } - - /// - /// Checks whether an item has any children - /// - /// Id of the - /// True if the content has any children otherwise False - public bool HasChildren(int id) => CountChildren(id) > 0; - - - /// - public IDictionary> GetContentSchedulesByIds(Guid[] keys) - { - if (keys.Length == 0) - { - return ImmutableDictionary>.Empty; - } - - List contentIds = []; - foreach (var key in keys) - { - Attempt contentId = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document); - if (contentId.Success is false) - { - continue; - } - - contentIds.Add(contentId.Result); - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentSchedulesByIds(contentIds.ToArray()); - } - } - - /// - /// Checks if the passed in can be published based on the ancestors publish state. - /// - /// to check if ancestors are published - /// True if the Content can be published, otherwise False - public bool IsPathPublishable(IContent content) - { - // fast - if (content.ParentId == Constants.System.Root) - { - return true; // root content is always publishable - } - - if (content.Trashed) - { - return false; // trashed content is never publishable - } - - // not trashed and has a parent: publishable if the parent is path-published - IContent? parent = GetById(content.ParentId); - return parent == null || IsPathPublished(parent); - } - - public bool IsPathPublished(IContent? content) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.IsPathPublished(content); - } - } - - #endregion - - #region Save, Publish, Unpublish - - /// - public OperationResult Save(IContent content, int? userId = null, ContentScheduleCollection? contentSchedule = null) - { - PublishedState publishedState = content.PublishedState; - if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) - { - throw new InvalidOperationException( - $"Cannot save (un)publishing content with name: {content.Name} - and state: {content.PublishedState}, use the dedicated SavePublished method."); - } - - if (content.Name != null && content.Name.Length > 255) - { - throw new InvalidOperationException( - $"Content with the name {content.Name} cannot be more than 255 characters in length."); - } - - EventMessages eventMessages = EventMessagesFactory.Get(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - - var savingNotification = new ContentSavingNotification(content, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return OperationResult.Cancel(eventMessages); - } - - userId ??= Constants.Security.SuperUserId; - - if (content.HasIdentity == false) - { - content.CreatorId = userId.Value; - } - - content.WriterId = userId.Value; - - // track the cultures that have changed - List? culturesChanging = content.ContentType.VariesByCulture() - ? content.CultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() - : null; - - // TODO: Currently there's no way to change track which variant properties have changed, we only have change - // tracking enabled on all values on the Property which doesn't allow us to know which variants have changed. - // in this particular case, determining which cultures have changed works with the above with names since it will - // have always changed if it's been saved in the back office but that's not really fail safe. - _documentRepository.Save(content); - - if (contentSchedule != null) - { - _documentRepository.PersistContentSchedule(content, contentSchedule); - } - - scope.Notifications.Publish( - new ContentSavedNotification(content, eventMessages).WithStateFrom(savingNotification)); - - // TODO: we had code here to FORCE that this event can never be suppressed. But that just doesn't make a ton of sense?! - // I understand that if its suppressed that the caches aren't updated, but that would be expected. If someone - // is supressing events then I think it's expected that nothing will happen. They are probably doing it for perf - // reasons like bulk import and in those cases we don't want this occuring. - scope.Notifications.Publish( - new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshNode, eventMessages)); - - if (culturesChanging != null) - { - var langs = GetLanguageDetailsForAuditEntry(culturesChanging); - Audit(AuditType.SaveVariant, userId.Value, content.Id, $"Saved languages: {langs}", langs); - } - else - { - Audit(AuditType.Save, userId.Value, content.Id); - } - - scope.Complete(); - } - - return OperationResult.Succeed(eventMessages); - } - - /// - public OperationResult Save(IEnumerable contents, int userId = Constants.Security.SuperUserId) - { - EventMessages eventMessages = EventMessagesFactory.Get(); - IContent[] contentsA = contents.ToArray(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - - var savingNotification = new ContentSavingNotification(contentsA, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return OperationResult.Cancel(eventMessages); - } - - foreach (IContent content in contentsA) - { - if (content.HasIdentity == false) - { - content.CreatorId = userId; - } - - content.WriterId = userId; - - _documentRepository.Save(content); - } - - scope.Notifications.Publish( - new ContentSavedNotification(contentsA, eventMessages).WithStateFrom(savingNotification)); - - // TODO: See note above about supressing events - scope.Notifications.Publish( - new ContentTreeChangeNotification(contentsA, TreeChangeTypes.RefreshNode, eventMessages)); - - string contentIds = string.Join(", ", contentsA.Select(x => x.Id)); - Audit(AuditType.Save, userId, Constants.System.Root, $"Saved multiple content items (#{contentIds.Length})"); - - scope.Complete(); - } - - return OperationResult.Succeed(eventMessages); - } - - /// - public PublishResult Publish(IContent content, string[] cultures, int userId = Constants.Security.SuperUserId) - { - if (content == null) - { - throw new ArgumentNullException(nameof(content)); - } - - if (cultures is null) - { - throw new ArgumentNullException(nameof(cultures)); - } - - if (cultures.Any(c => c.IsNullOrWhiteSpace()) || cultures.Distinct().Count() != cultures.Length) - { - throw new ArgumentException("Cultures cannot be null or whitespace", nameof(cultures)); - } - - cultures = cultures.Select(x => x.EnsureCultureCode()!).ToArray(); - - EventMessages evtMsgs = EventMessagesFactory.Get(); - - // we need to guard against unsaved changes before proceeding; the content will be saved, but we're not firing any saved notifications - if (HasUnsavedChanges(content)) - { - return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, content); - } - - if (content.Name != null && content.Name.Length > 255) - { - throw new InvalidOperationException("Name cannot be more than 255 characters in length."); - } - - PublishedState publishedState = content.PublishedState; - if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) - { - throw new InvalidOperationException( - $"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); - } - - // cannot accept invariant (null or empty) culture for variant content type - // cannot accept a specific culture for invariant content type (but '*' is ok) - if (content.ContentType.VariesByCulture()) - { - if (cultures.Length > 1 && cultures.Contains("*")) - { - throw new ArgumentException("Cannot combine wildcard and specific cultures when publishing variant content types.", nameof(cultures)); - } - } - else - { - if (cultures.Length == 0) - { - cultures = new[] { "*" }; - } - - if (cultures[0] != "*" || cultures.Length > 1) - { - throw new ArgumentException($"Only wildcard culture is supported when publishing invariant content types.", nameof(cultures)); - } - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - - var allLangs = _languageRepository.GetMany().ToList(); - - // this will create the correct culture impact even if culture is * or null - IEnumerable impacts = - cultures.Select(culture => _cultureImpactFactory.Create(culture, IsDefaultCulture(allLangs, culture), content)); - - // publish the culture(s) - // we don't care about the response here, this response will be rechecked below but we need to set the culture info values now. - var publishTime = DateTime.UtcNow; - foreach (CultureImpact? impact in impacts) - { - content.PublishCulture(impact, publishTime, _propertyEditorCollection); - } - - // Change state to publishing - content.PublishedState = PublishedState.Publishing; - - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, new Dictionary(), userId); - scope.Complete(); - return result; - } - } - - /// - public PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId) - { - if (content == null) - { - throw new ArgumentNullException(nameof(content)); - } - - EventMessages evtMsgs = EventMessagesFactory.Get(); - - culture = culture?.NullOrWhiteSpaceAsNull().EnsureCultureCode(); - - PublishedState publishedState = content.PublishedState; - if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) - { - throw new InvalidOperationException( - $"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); - } - - // cannot accept invariant (null or empty) culture for variant content type - // cannot accept a specific culture for invariant content type (but '*' is ok) - if (content.ContentType.VariesByCulture()) - { - if (culture == null) - { - throw new NotSupportedException("Invariant culture is not supported by variant content types."); - } - } - else - { - if (culture != null && culture != "*") - { - throw new NotSupportedException( - $"Culture \"{culture}\" is not supported by invariant content types."); - } - } - - // if the content is not published, nothing to do - if (!content.Published) - { - return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - - var allLangs = _languageRepository.GetMany().ToList(); - - var savingNotification = new ContentSavingNotification(content, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); - } - - // all cultures = unpublish whole - if (culture == "*" || (!content.ContentType.VariesByCulture() && culture == null)) - { - // Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will - // essentially be re-publishing the document with the requested culture removed - // We are however unpublishing all cultures, so we will set this to unpublishing. - content.UnpublishCulture(culture); - content.PublishedState = PublishedState.Unpublishing; - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); - scope.Complete(); - return result; - } - else - { - // Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will - // essentially be re-publishing the document with the requested culture removed. - // The call to CommitDocumentChangesInternal will perform all the checks like if this is a mandatory culture or the last culture being unpublished - // and will then unpublish the document accordingly. - // If the result of this is false it means there was no culture to unpublish (i.e. it was already unpublished or it did not exist) - var removed = content.UnpublishCulture(culture); - - // Save and publish any changes - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); - - scope.Complete(); - - // In one case the result will be PublishStatusType.FailedPublishNothingToPublish which means that no cultures - // were specified to be published which will be the case when removed is false. In that case - // we want to swap the result type to PublishResultType.SuccessUnpublishAlready (that was the expectation before). - if (result.Result == PublishResultType.FailedPublishNothingToPublish && !removed) - { - return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); - } - - return result; - } - } - } - - /// - /// Publishes/unpublishes any pending publishing changes made to the document. - /// - /// - /// - /// This MUST NOT be called from within this service, this used to be a public API and must only be used outside of - /// this service. - /// Internally in this service, calls must be made to CommitDocumentChangesInternal - /// - /// This is the underlying logic for both publishing and unpublishing any document - /// - /// Pending publishing/unpublishing changes on a document are made with calls to - /// and - /// . - /// - /// - /// When publishing or unpublishing a single culture, or all cultures, use the publishing operations - /// and . But if the flexibility to both publish and unpublish in a single operation is - /// required, then this method needs to be used in combination with - /// and - /// on the content itself - this prepares the content, but does not commit anything - and then, invoke - /// to actually commit the changes to the database. - /// - /// The document is *always* saved, even when publishing fails. - /// - internal PublishResult CommitDocumentChanges(IContent content, int userId = Constants.Security.SuperUserId) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - EventMessages evtMsgs = EventMessagesFactory.Get(); - - scope.WriteLock(Constants.Locks.ContentTree); - - var savingNotification = new ContentSavingNotification(content, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); - } - - var allLangs = _languageRepository.GetMany().ToList(); - - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); - scope.Complete(); - return result; - } - } - - /// - /// Handles a lot of business logic cases for how the document should be persisted - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// Business logic cases such: as unpublishing a mandatory culture, or unpublishing the last culture, checking for - /// pending scheduled publishing, etc... is dealt with in this method. - /// There is quite a lot of cases to take into account along with logic that needs to deal with scheduled - /// saving/publishing, branch saving/publishing, etc... - /// - /// - private PublishResult CommitDocumentChangesInternal( - ICoreScope scope, - IContent content, - EventMessages eventMessages, - IReadOnlyCollection allLangs, - IDictionary? notificationState, - int userId, - bool branchOne = false, - bool branchRoot = false) - { - if (scope == null) - { - throw new ArgumentNullException(nameof(scope)); - } - - if (content == null) - { - throw new ArgumentNullException(nameof(content)); - } - - if (eventMessages == null) - { - throw new ArgumentNullException(nameof(eventMessages)); - } - - PublishResult? publishResult = null; - PublishResult? unpublishResult = null; - - // nothing set = republish it all - if (content.PublishedState != PublishedState.Publishing && - content.PublishedState != PublishedState.Unpublishing) - { - content.PublishedState = PublishedState.Publishing; - } - - // State here is either Publishing or Unpublishing - // Publishing to unpublish a culture may end up unpublishing everything so these flags can be flipped later - var publishing = content.PublishedState == PublishedState.Publishing; - var unpublishing = content.PublishedState == PublishedState.Unpublishing; - - var variesByCulture = content.ContentType.VariesByCulture(); - - // Track cultures that are being published, changed, unpublished - IReadOnlyList? culturesPublishing = null; - IReadOnlyList? culturesUnpublishing = null; - IReadOnlyList? culturesChanging = variesByCulture - ? content.CultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() - : null; - - var isNew = !content.HasIdentity; - TreeChangeTypes changeType = isNew ? TreeChangeTypes.RefreshNode : TreeChangeTypes.RefreshBranch; - var previouslyPublished = content.HasIdentity && content.Published; - - // Inline method to persist the document with the documentRepository since this logic could be called a couple times below - void SaveDocument(IContent c) - { - // save, always - if (c.HasIdentity == false) - { - c.CreatorId = userId; - } - - c.WriterId = userId; - - // saving does NOT change the published version, unless PublishedState is Publishing or Unpublishing - _documentRepository.Save(c); - } - - if (publishing) - { - // Determine cultures publishing/unpublishing which will be based on previous calls to content.PublishCulture and ClearPublishInfo - culturesUnpublishing = content.GetCulturesUnpublishing(); - culturesPublishing = variesByCulture - ? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() - : null; - - // ensure that the document can be published, and publish handling events, business rules, etc - publishResult = StrategyCanPublish( - scope, - content, /*checkPath:*/ - !branchOne || branchRoot, - culturesPublishing, - culturesUnpublishing, - eventMessages, - allLangs, - notificationState); - - if (publishResult.Success) - { - // raise Publishing notification - if (scope.Notifications.PublishCancelable( - new ContentPublishingNotification(content, eventMessages).WithState(notificationState))) - { - _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "publishing was cancelled"); - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, eventMessages, content); - } - - // note: StrategyPublish flips the PublishedState to Publishing! - publishResult = StrategyPublish(content, culturesPublishing, culturesUnpublishing, eventMessages); - - // Check if a culture has been unpublished and if there are no cultures left, and then unpublish document as a whole - if (publishResult.Result == PublishResultType.SuccessUnpublishCulture && - content.PublishCultureInfos?.Count == 0) - { - // This is a special case! We are unpublishing the last culture and to persist that we need to re-publish without any cultures - // so the state needs to remain Publishing to do that. However, we then also need to unpublish the document and to do that - // the state needs to be Unpublishing and it cannot be both. This state is used within the documentRepository to know how to - // persist certain things. So before proceeding below, we need to save the Publishing state to publish no cultures, then we can - // mark the document for Unpublishing. - SaveDocument(content); - - // Set the flag to unpublish and continue - unpublishing = content.Published; // if not published yet, nothing to do - } - } - else - { - // in a branch, just give up - if (branchOne && !branchRoot) - { - return publishResult; - } - - // Check for mandatory culture missing, and then unpublish document as a whole - if (publishResult.Result == PublishResultType.FailedPublishMandatoryCultureMissing) - { - publishing = false; - unpublishing = content.Published; // if not published yet, nothing to do - - // we may end up in a state where we won't publish nor unpublish - // keep going, though, as we want to save anyways - } - - // reset published state from temp values (publishing, unpublishing) to original value - // (published, unpublished) in order to save the document, unchanged - yes, this is odd, - // but: (a) it means we don't reproduce the PublishState logic here and (b) setting the - // PublishState to anything other than Publishing or Unpublishing - which is precisely - // what we want to do here - throws - content.Published = content.Published; - } - } - - // won't happen in a branch - if (unpublishing) - { - IContent? newest = GetById(content.Id); // ensure we have the newest version - in scope - if (content.VersionId != newest?.VersionId) - { - return new PublishResult(PublishResultType.FailedPublishConcurrencyViolation, eventMessages, content); - } - - if (content.Published) - { - // ensure that the document can be unpublished, and unpublish - // handling events, business rules, etc - // note: StrategyUnpublish flips the PublishedState to Unpublishing! - // note: This unpublishes the entire document (not different variants) - unpublishResult = StrategyCanUnpublish(scope, content, eventMessages, notificationState); - if (unpublishResult.Success) - { - unpublishResult = StrategyUnpublish(content, eventMessages); - } - else - { - // reset published state from temp values (publishing, unpublishing) to original value - // (published, unpublished) in order to save the document, unchanged - yes, this is odd, - // but: (a) it means we don't reproduce the PublishState logic here and (b) setting the - // PublishState to anything other than Publishing or Unpublishing - which is precisely - // what we want to do here - throws - content.Published = content.Published; - return unpublishResult; - } - } - else - { - // already unpublished - optimistic concurrency collision, really, - // and I am not sure at all what we should do, better die fast, else - // we may end up corrupting the db - throw new InvalidOperationException("Concurrency collision."); - } - } - - // Persist the document - SaveDocument(content); - - // we have tried to unpublish - won't happen in a branch - if (unpublishing) - { - // and succeeded, trigger events - if (unpublishResult?.Success ?? false) - { - // events and audit - scope.Notifications.Publish( - new ContentUnpublishedNotification(content, eventMessages).WithState(notificationState)); - scope.Notifications.Publish(new ContentTreeChangeNotification( - content, - TreeChangeTypes.RefreshBranch, - variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : null, - variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : ["*"], - eventMessages)); - - if (culturesUnpublishing != null) - { - // This will mean that that we unpublished a mandatory culture or we unpublished the last culture. - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing); - Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); - - if (publishResult == null) - { - throw new PanicException("publishResult == null - should not happen"); - } - - switch (publishResult.Result) - { - case PublishResultType.FailedPublishMandatoryCultureMissing: - // Occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture) - - // Log that the whole content item has been unpublished due to mandatory culture unpublished - Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (mandatory language unpublished)"); - return new PublishResult(PublishResultType.SuccessUnpublishMandatoryCulture, eventMessages, content); - case PublishResultType.SuccessUnpublishCulture: - // Occurs when the last culture is unpublished - Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (last language unpublished)"); - return new PublishResult(PublishResultType.SuccessUnpublishLastCulture, eventMessages, content); - } - } - - Audit(AuditType.Unpublish, userId, content.Id); - return new PublishResult(PublishResultType.SuccessUnpublish, eventMessages, content); - } - - // or, failed - scope.Notifications.Publish(new ContentTreeChangeNotification(content, changeType, eventMessages)); - return new PublishResult(PublishResultType.FailedUnpublish, eventMessages, content); // bah - } - - // we have tried to publish - if (publishing) - { - // and succeeded, trigger events - if (publishResult?.Success ?? false) - { - if (isNew == false && previouslyPublished == false) - { - changeType = TreeChangeTypes.RefreshBranch; // whole branch - } - else if (isNew == false && previouslyPublished) - { - changeType = TreeChangeTypes.RefreshNode; // single node - } - - // invalidate the node/branch - // for branches, handled by SaveAndPublishBranch - if (!branchOne) - { - scope.Notifications.Publish( - new ContentTreeChangeNotification( - content, - changeType, - variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : ["*"], - variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : null, - eventMessages)); - scope.Notifications.Publish( - new ContentPublishedNotification(content, eventMessages).WithState(notificationState)); - } - - // it was not published and now is... descendants that were 'published' (but - // had an unpublished ancestor) are 're-published' ie not explicitly published - // but back as 'published' nevertheless - if (!branchOne && isNew == false && previouslyPublished == false && HasChildren(content.Id)) - { - IContent[] descendants = GetPublishedDescendantsLocked(content).ToArray(); - scope.Notifications.Publish( - new ContentPublishedNotification(descendants, eventMessages).WithState(notificationState)); - } - - switch (publishResult.Result) - { - case PublishResultType.SuccessPublish: - Audit(AuditType.Publish, userId, content.Id); - break; - case PublishResultType.SuccessPublishCulture: - if (culturesPublishing != null) - { - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesPublishing); - Audit(AuditType.PublishVariant, userId, content.Id, $"Published languages: {langs}", langs); - } - - break; - case PublishResultType.SuccessUnpublishCulture: - if (culturesUnpublishing != null) - { - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing); - Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); - } - - break; - } - - return publishResult; - } - } - - // should not happen - if (branchOne && !branchRoot) - { - throw new PanicException("branchOne && !branchRoot - should not happen"); - } - - // if publishing didn't happen or if it has failed, we still need to log which cultures were saved - if (!branchOne && (publishResult == null || !publishResult.Success)) - { - if (culturesChanging != null) - { - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesChanging); - Audit(AuditType.SaveVariant, userId, content.Id, $"Saved languages: {langs}", langs); - } - else - { - Audit(AuditType.Save, userId, content.Id); - } - } - - // or, failed - scope.Notifications.Publish(new ContentTreeChangeNotification(content, changeType, eventMessages)); - return publishResult!; - } - - /// - public IEnumerable PerformScheduledPublish(DateTime date) - { - var allLangs = new Lazy>(() => _languageRepository.GetMany().ToList()); - EventMessages evtMsgs = EventMessagesFactory.Get(); - var results = new List(); - - PerformScheduledPublishingRelease(date, results, evtMsgs, allLangs); - PerformScheduledPublishingExpiration(date, results, evtMsgs, allLangs); - - return results; - } - - private void PerformScheduledPublishingExpiration(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs) - { - using ICoreScope scope = ScopeProvider.CreateCoreScope(); - - // do a fast read without any locks since this executes often to see if we even need to proceed - if (_documentRepository.HasContentForExpiration(date)) - { - // now take a write lock since we'll be updating - scope.WriteLock(Constants.Locks.ContentTree); - - foreach (IContent d in _documentRepository.GetContentForExpiration(date)) - { - ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(d.Id); - if (d.ContentType.VariesByCulture()) - { - // find which cultures have pending schedules - var pendingCultures = contentSchedule.GetPending(ContentScheduleAction.Expire, date) - .Select(x => x.Culture) - .Distinct() - .ToList(); - - if (pendingCultures.Count == 0) - { - continue; // shouldn't happen but no point in processing this document if there's nothing there - } - - var savingNotification = new ContentSavingNotification(d, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - results.Add(new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d)); - continue; - } - - foreach (var c in pendingCultures) - { - // Clear this schedule for this culture - contentSchedule.Clear(c, ContentScheduleAction.Expire, date); - - // set the culture to be published - d.UnpublishCulture(c); - } - - _documentRepository.PersistContentSchedule(d, contentSchedule); - PublishResult result = CommitDocumentChangesInternal(scope, d, evtMsgs, allLangs.Value, savingNotification.State, d.WriterId); - if (result.Success == false) - { - _logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); - } - - results.Add(result); - } - else - { - // Clear this schedule for this culture - contentSchedule.Clear(ContentScheduleAction.Expire, date); - _documentRepository.PersistContentSchedule(d, contentSchedule); - PublishResult result = Unpublish(d, userId: d.WriterId); - if (result.Success == false) - { - _logger.LogError(null, "Failed to unpublish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); - } - - results.Add(result); - } - } + /// + /// Gets a collection of an objects, which resides in the Recycle Bin + /// + /// An Enumerable list of objects + public IEnumerable GetPagedContentInRecycleBin(long pageIndex, int pageSize, out long totalRecords, IQuery? filter = null, Ordering? ordering = null) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + ordering ??= Ordering.By("Path"); - _documentRepository.ClearSchedule(date, ContentScheduleAction.Expire); + scope.ReadLock(Constants.Locks.ContentTree); + IQuery? query = Query()? + .Where(x => x.Path.StartsWith(Constants.System.RecycleBinContentPathPrefix)); + return _documentRepository.GetPage(query, pageIndex, pageSize, out totalRecords, propertyAliases: null, filter, ordering); } - - scope.Complete(); } - private void PerformScheduledPublishingRelease(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs) + /// + public IDictionary> GetContentSchedulesByIds(Guid[] keys) { - using ICoreScope scope = ScopeProvider.CreateCoreScope(); - - // do a fast read without any locks since this executes often to see if we even need to proceed - if (_documentRepository.HasContentForRelease(date)) + if (keys.Length == 0) { - // now take a write lock since we'll be updating - scope.WriteLock(Constants.Locks.ContentTree); + return ImmutableDictionary>.Empty; + } - foreach (IContent d in _documentRepository.GetContentForRelease(date)) + List contentIds = []; + foreach (var key in keys) + { + Attempt contentId = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document); + if (contentId.Success is false) { - ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(d.Id); - if (d.ContentType.VariesByCulture()) - { - // find which cultures have pending schedules - var pendingCultures = contentSchedule.GetPending(ContentScheduleAction.Release, date) - .Select(x => x.Culture) - .Distinct() - .ToList(); - - if (pendingCultures.Count == 0) - { - continue; // shouldn't happen but no point in processing this document if there's nothing there - } - var savingNotification = new ContentSavingNotification(d, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - results.Add(new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d)); - continue; - } - - - var publishing = true; - foreach (var culture in pendingCultures) - { - // Clear this schedule for this culture - contentSchedule.Clear(culture, ContentScheduleAction.Release, date); - - if (d.Trashed) - { - continue; // won't publish - } + continue; + } - // publish the culture values and validate the property values, if validation fails, log the invalid properties so the develeper has an idea of what has failed - IProperty[]? invalidProperties = null; - CultureImpact impact = _cultureImpactFactory.ImpactExplicit(culture, IsDefaultCulture(allLangs.Value, culture)); - var tryPublish = d.PublishCulture(impact, date, _propertyEditorCollection) && - _propertyValidationService.Value.IsPropertyDataValid(d, out invalidProperties, impact); - if (invalidProperties != null && invalidProperties.Length > 0) - { - _logger.LogWarning( - "Scheduled publishing will fail for document {DocumentId} and culture {Culture} because of invalid properties {InvalidProperties}", - d.Id, - culture, - string.Join(",", invalidProperties.Select(x => x.Alias))); - } + contentIds.Add(contentId.Result); + } - publishing &= tryPublish; // set the culture to be published - if (!publishing) - { - } - } + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(Constants.Locks.ContentTree); + return _documentRepository.GetContentSchedulesByIds(contentIds.ToArray()); + } + } - PublishResult result; + /// + /// Checks if the passed in can be published based on the ancestors publish state. + /// + /// to check if ancestors are published + /// True if the Content can be published, otherwise False + public bool IsPathPublishable(IContent content) + { + // fast + if (content.ParentId == Constants.System.Root) + { + return true; // root content is always publishable + } - if (d.Trashed) - { - result = new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d); - } - else if (!publishing) - { - result = new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, d); - } - else - { - _documentRepository.PersistContentSchedule(d, contentSchedule); - result = CommitDocumentChangesInternal(scope, d, evtMsgs, allLangs.Value, savingNotification.State, d.WriterId); - } + if (content.Trashed) + { + return false; // trashed content is never publishable + } - if (result.Success == false) - { - _logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); - } + // not trashed and has a parent: publishable if the parent is path-published + IContent? parent = GetById(content.ParentId); + return parent == null || IsPathPublished(parent); + } - results.Add(result); - } - else - { - // Clear this schedule - contentSchedule.Clear(ContentScheduleAction.Release, date); + #endregion - PublishResult? result = null; + #region Save, Publish, Unpublish - if (d.Trashed) - { - result = new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d); - } - else - { - _documentRepository.PersistContentSchedule(d, contentSchedule); - result = Publish(d, d.AvailableCultures.ToArray(), userId: d.WriterId); - } + /// + /// Publishes/unpublishes any pending publishing changes made to the document. + /// + /// + /// + /// This MUST NOT be called from within this service, this used to be a public API and must only be used outside of + /// this service. + /// Internally in this service, calls must be made to CommitDocumentChangesInternal + /// + /// This is the underlying logic for both publishing and unpublishing any document + /// + /// Pending publishing/unpublishing changes on a document are made with calls to + /// and + /// . + /// + /// + /// When publishing or unpublishing a single culture, or all cultures, use the publishing operations + /// and . But if the flexibility to both publish and unpublish in a single operation is + /// required, then this method needs to be used in combination with + /// and + /// on the content itself - this prepares the content, but does not commit anything - and then, invoke + /// to actually commit the changes to the database. + /// + /// The document is *always* saved, even when publishing fails. + /// + internal PublishResult CommitDocumentChanges(IContent content, int userId = Constants.Security.SuperUserId) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + EventMessages evtMsgs = EventMessagesFactory.Get(); - if (result.Success == false) - { - _logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); - } + scope.WriteLock(Constants.Locks.ContentTree); - results.Add(result); - } + var savingNotification = new ContentSavingNotification(content, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); } - _documentRepository.ClearSchedule(date, ContentScheduleAction.Release); - } + var allLangs = _languageRepository.GetMany().ToList(); - scope.Complete(); + PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + scope.Complete(); + return result; + } } // utility 'PublishCultures' func used by SaveAndPublishBranch @@ -2251,191 +1002,41 @@ internal IEnumerable PublishBranch( // we need to guard against unsaved changes before proceeding; the document will be saved, but we're not firing any saved notifications if (HasUnsavedChanges(document)) { - return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, document); - } - - // null = do not include - if (culturesToPublish == null) - { - return null; - } - - // empty = already published - if (culturesToPublish.Count == 0) - { - return new PublishResult(PublishResultType.SuccessPublishAlready, evtMsgs, document); - } - - var savingNotification = new ContentSavingNotification(document, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, document); - } - - // publish & check if values are valid - if (!publishCultures(document, culturesToPublish, allLangs)) - { - // TODO: Based on this callback behavior there is no way to know which properties may have been invalid if this failed, see other results of FailedPublishContentInvalid - return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, document); - } - - PublishResult result = CommitDocumentChangesInternal(scope, document, evtMsgs, allLangs, savingNotification.State, userId, true, isRoot); - if (result.Success) - { - publishedDocuments.Add(document); - } - - return result; - } - - #endregion - - #region Delete - - /// - public OperationResult Delete(IContent content, int userId = Constants.Security.SuperUserId) - { - EventMessages eventMessages = EventMessagesFactory.Get(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - - if (scope.Notifications.PublishCancelable(new ContentDeletingNotification(content, eventMessages))) - { - scope.Complete(); - return OperationResult.Cancel(eventMessages); - } - - // if it's not trashed yet, and published, we should unpublish - // but... Unpublishing event makes no sense (not going to cancel?) and no need to save - // just raise the event - if (content.Trashed == false && content.Published) - { - scope.Notifications.Publish(new ContentUnpublishedNotification(content, eventMessages)); - } - - DeleteLocked(scope, content, eventMessages); - - scope.Notifications.Publish( - new ContentTreeChangeNotification(content, TreeChangeTypes.Remove, eventMessages)); - Audit(AuditType.Delete, userId, content.Id); - - scope.Complete(); - } - - return OperationResult.Succeed(eventMessages); - } - - private void DeleteLocked(ICoreScope scope, IContent content, EventMessages evtMsgs) - { - void DoDelete(IContent c) - { - _documentRepository.Delete(c); - scope.Notifications.Publish(new ContentDeletedNotification(c, evtMsgs)); - - // media files deleted by QueuingEventDispatcher - } - - const int pageSize = 500; - var total = long.MaxValue; - while (total > 0) - { - // get descendants - ordered from deepest to shallowest - IEnumerable descendants = GetPagedDescendants(content.Id, 0, pageSize, out total, ordering: Ordering.By("Path", Direction.Descending)); - foreach (IContent c in descendants) - { - DoDelete(c); - } - } - - DoDelete(content); - } - - // TODO: both DeleteVersions methods below have an issue. Sort of. They do NOT take care of files the way - // Delete does - for a good reason: the file may be referenced by other, non-deleted, versions. BUT, - // if that's not the case, then the file will never be deleted, because when we delete the content, - // the version referencing the file will not be there anymore. SO, we can leak files. - - /// - /// Permanently deletes versions from an object prior to a specific date. - /// This method will never delete the latest version of a content item. - /// - /// Id of the object to delete versions from - /// Latest version date - /// Optional Id of the User deleting versions of a Content object - public void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId) - { - EventMessages evtMsgs = EventMessagesFactory.Get(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - - var deletingVersionsNotification = - new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: versionDate); - if (scope.Notifications.PublishCancelable(deletingVersionsNotification)) - { - scope.Complete(); - return; - } - - _documentRepository.DeleteVersions(id, versionDate); - - scope.Notifications.Publish( - new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: versionDate).WithStateFrom( - deletingVersionsNotification)); - Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version date)"); - - scope.Complete(); - } - } - - /// - /// Permanently deletes specific version(s) from an object. - /// This method will never delete the latest version of a content item. - /// - /// Id of the object to delete a version from - /// Id of the version to delete - /// Boolean indicating whether to delete versions prior to the versionId - /// Optional Id of the User deleting versions of a Content object - public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = Constants.Security.SuperUserId) - { - EventMessages evtMsgs = EventMessagesFactory.Get(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - - var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, versionId); - if (scope.Notifications.PublishCancelable(deletingVersionsNotification)) - { - scope.Complete(); - return; - } + return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, document); + } - if (deletePriorVersions) - { - IContent? content = GetVersion(versionId); - DeleteVersions(id, content?.UpdateDate ?? DateTime.UtcNow, userId); - } + // null = do not include + if (culturesToPublish == null) + { + return null; + } - IContent? c = _documentRepository.Get(id); + // empty = already published + if (culturesToPublish.Count == 0) + { + return new PublishResult(PublishResultType.SuccessPublishAlready, evtMsgs, document); + } - // don't delete the current or published version - if (c?.VersionId != versionId && - c?.PublishedVersionId != versionId) - { - _documentRepository.DeleteVersion(versionId); - } + var savingNotification = new ContentSavingNotification(document, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, document); + } - scope.Notifications.Publish( - new ContentDeletedVersionsNotification(id, evtMsgs, versionId).WithStateFrom( - deletingVersionsNotification)); - Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version)"); + // publish & check if values are valid + if (!publishCultures(document, culturesToPublish, allLangs)) + { + // TODO: Based on this callback behavior there is no way to know which properties may have been invalid if this failed, see other results of FailedPublishContentInvalid + return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, document); + } - scope.Complete(); + PublishResult result = CommitDocumentChangesInternal(scope, document, evtMsgs, allLangs, savingNotification.State, userId, true, isRoot); + if (result.Success) + { + publishedDocuments.Add(document); } + + return result; } #endregion @@ -3060,8 +1661,6 @@ private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, Ev return OperationResult.Succeed(eventMessages); } - private static bool HasUnsavedChanges(IContent content) => content.HasIdentity is false || content.IsDirty(); - public ContentDataIntegrityReport CheckDataIntegrity(ContentDataIntegrityReportOptions options) { using (ICoreScope scope = ScopeProvider.CreateCoreScope()) @@ -3101,396 +1700,16 @@ internal IEnumerable GetPublishedDescendants(IContent content) } } - internal IEnumerable GetPublishedDescendantsLocked(IContent content) - { - var pathMatch = content.Path + ","; - IQuery query = Query() - .Where(x => x.Id != content.Id && x.Path.StartsWith(pathMatch) /*&& culture.Trashed == false*/); - IEnumerable contents = _documentRepository.Get(query); - - // beware! contents contains all published version below content - // including those that are not directly published because below an unpublished content - // these must be filtered out here - var parents = new List { content.Id }; - if (contents is not null) - { - foreach (IContent c in contents) - { - if (parents.Contains(c.ParentId)) - { - yield return c; - parents.Add(c.Id); - } - } - } - } - #endregion #region Private Methods - private void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) => - AuditAsync(type, userId, objectId, message, parameters).GetAwaiter().GetResult(); - - private async Task AuditAsync(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) - { - Guid userKey = await _userIdKeyResolver.GetAsync(userId); - - await _auditService.AddAsync( - type, - userKey, - objectId, - UmbracoObjectTypes.Document.GetName(), - message, - parameters); - } - - private string GetLanguageDetailsForAuditEntry(IEnumerable affectedCultures) - => GetLanguageDetailsForAuditEntry(_languageRepository.GetMany(), affectedCultures); - - private static string GetLanguageDetailsForAuditEntry(IEnumerable languages, IEnumerable affectedCultures) - { - IEnumerable languageIsoCodes = languages - .Where(x => affectedCultures.InvariantContains(x.IsoCode)) - .Select(x => x.IsoCode); - return string.Join(", ", languageIsoCodes); - } - - private static bool IsDefaultCulture(IReadOnlyCollection? langs, string culture) => - langs?.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)) ?? false; - + // TODO ELEMENTS: not used? clean up! private bool IsMandatoryCulture(IReadOnlyCollection langs, string culture) => langs.Any(x => x.IsMandatory && x.IsoCode.InvariantEquals(culture)); #endregion - #region Publishing Strategies - - /// - /// Ensures that a document can be published - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - private PublishResult StrategyCanPublish( - ICoreScope scope, - IContent content, - bool checkPath, - IReadOnlyList? culturesPublishing, - IReadOnlyCollection? culturesUnpublishing, - EventMessages evtMsgs, - IReadOnlyCollection allLangs, - IDictionary? notificationState) - { - var variesByCulture = content.ContentType.VariesByCulture(); - - // If it's null it's invariant - CultureImpact[] impactsToPublish = culturesPublishing == null - ? new[] { _cultureImpactFactory.ImpactInvariant() } - : culturesPublishing.Select(x => - _cultureImpactFactory.ImpactExplicit( - x, - allLangs.Any(lang => lang.IsoCode.InvariantEquals(x) && lang.IsMandatory))) - .ToArray(); - - // publish the culture(s) - var publishTime = DateTime.UtcNow; - if (!impactsToPublish.All(impact => content.PublishCulture(impact, publishTime, _propertyEditorCollection))) - { - return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content); - } - - // Validate the property values - IProperty[]? invalidProperties = null; - if (!impactsToPublish.All(x => - _propertyValidationService.Value.IsPropertyDataValid(content, out invalidProperties, x))) - { - return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content) - { - InvalidProperties = invalidProperties, - }; - } - - // Check if mandatory languages fails, if this fails it will mean anything that the published flag on the document will - // be changed to Unpublished and any culture currently published will not be visible. - if (variesByCulture) - { - if (culturesPublishing == null) - { - throw new InvalidOperationException( - "Internal error, variesByCulture but culturesPublishing is null."); - } - - if (content.Published && culturesPublishing.Count == 0 && culturesUnpublishing?.Count == 0) - { - // no published cultures = cannot be published - // This will occur if for example, a culture that is already unpublished is sent to be unpublished again, or vice versa, in that case - // there will be nothing to publish/unpublish. - return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); - } - - // missing mandatory culture = cannot be published - IEnumerable mandatoryCultures = allLangs.Where(x => x.IsMandatory).Select(x => x.IsoCode); - var mandatoryMissing = mandatoryCultures.Any(x => - !content.PublishedCultures.Contains(x, StringComparer.OrdinalIgnoreCase)); - if (mandatoryMissing) - { - return new PublishResult(PublishResultType.FailedPublishMandatoryCultureMissing, evtMsgs, content); - } - - if (culturesPublishing.Count == 0 && culturesUnpublishing?.Count > 0) - { - return new PublishResult(PublishResultType.SuccessUnpublishCulture, evtMsgs, content); - } - } - - // ensure that the document has published values - // either because it is 'publishing' or because it already has a published version - if (content.PublishedState != PublishedState.Publishing && content.PublishedVersionId == 0) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", - content.Name, - content.Id, - "document does not have published values"); - return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); - } - - ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(content.Id); - - // loop over each culture publishing - or InvariantCulture for invariant - foreach (var culture in culturesPublishing ?? new[] { Constants.System.InvariantCulture }) - { - // ensure that the document status is correct - // note: culture will be string.Empty for invariant - switch (content.GetStatus(contentSchedule, culture)) - { - case ContentStatus.Expired: - if (!variesByCulture) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document has expired"); - } - else - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", content.Name, content.Id, culture, "document culture has expired"); - } - - return new PublishResult( - !variesByCulture - ? PublishResultType.FailedPublishHasExpired : PublishResultType.FailedPublishCultureHasExpired, - evtMsgs, - content); - - case ContentStatus.AwaitingRelease: - if (!variesByCulture) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", - content.Name, - content.Id, - "document is awaiting release"); - } - else - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", - content.Name, - content.Id, - culture, - "document has culture awaiting release"); - } - - return new PublishResult( - !variesByCulture - ? PublishResultType.FailedPublishAwaitingRelease - : PublishResultType.FailedPublishCultureAwaitingRelease, - evtMsgs, - content); - - case ContentStatus.Trashed: - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", - content.Name, - content.Id, - "document is trashed"); - return new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, content); - } - } - - if (checkPath) - { - // check if the content can be path-published - // root content can be published - // else check ancestors - we know we are not trashed - var pathIsOk = content.ParentId == Constants.System.Root || IsPathPublished(GetParent(content)); - if (!pathIsOk) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", - content.Name, - content.Id, - "parent is not published"); - return new PublishResult(PublishResultType.FailedPublishPathNotPublished, evtMsgs, content); - } - } - - // If we are both publishing and unpublishing cultures, then return a mixed status - if (variesByCulture && culturesPublishing?.Count > 0 && culturesUnpublishing?.Count > 0) - { - return new PublishResult(PublishResultType.SuccessMixedCulture, evtMsgs, content); - } - - return new PublishResult(evtMsgs, content); - } - - /// - /// Publishes a document - /// - /// - /// - /// - /// - /// - /// - /// It is assumed that all publishing checks have passed before calling this method like - /// - /// - private PublishResult StrategyPublish( - IContent content, - IReadOnlyCollection? culturesPublishing, - IReadOnlyCollection? culturesUnpublishing, - EventMessages evtMsgs) - { - // change state to publishing - content.PublishedState = PublishedState.Publishing; - - // if this is a variant then we need to log which cultures have been published/unpublished and return an appropriate result - if (content.ContentType.VariesByCulture()) - { - if (content.Published && culturesUnpublishing?.Count == 0 && culturesPublishing?.Count == 0) - { - return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); - } - - if (culturesUnpublishing?.Count > 0) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cultures: {Cultures} have been unpublished.", - content.Name, - content.Id, - string.Join(",", culturesUnpublishing)); - } - - if (culturesPublishing?.Count > 0) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cultures: {Cultures} have been published.", - content.Name, - content.Id, - string.Join(",", culturesPublishing)); - } - - if (culturesUnpublishing?.Count > 0 && culturesPublishing?.Count > 0) - { - return new PublishResult(PublishResultType.SuccessMixedCulture, evtMsgs, content); - } - - if (culturesUnpublishing?.Count > 0 && culturesPublishing?.Count == 0) - { - return new PublishResult(PublishResultType.SuccessUnpublishCulture, evtMsgs, content); - } - - return new PublishResult(PublishResultType.SuccessPublishCulture, evtMsgs, content); - } - - _logger.LogInformation("Document {ContentName} (id={ContentId}) has been published.", content.Name, content.Id); - return new PublishResult(evtMsgs, content); - } - - /// - /// Ensures that a document can be unpublished - /// - /// - /// - /// - /// - /// - private PublishResult StrategyCanUnpublish( - ICoreScope scope, - IContent content, - EventMessages evtMsgs, - IDictionary? notificationState) - { - // raise Unpublishing notification - ContentUnpublishingNotification notification = new ContentUnpublishingNotification(content, evtMsgs).WithState(notificationState); - var notificationResult = scope.Notifications.PublishCancelable(notification); - - if (notificationResult) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be unpublished: unpublishing was cancelled.", content.Name, content.Id); - return new PublishResult(PublishResultType.FailedUnpublishCancelledByEvent, evtMsgs, content); - } - - return new PublishResult(PublishResultType.SuccessUnpublish, evtMsgs, content); - } - - /// - /// Unpublishes a document - /// - /// - /// - /// - /// - /// It is assumed that all unpublishing checks have passed before calling this method like - /// - /// - private PublishResult StrategyUnpublish(IContent content, EventMessages evtMsgs) - { - var attempt = new PublishResult(PublishResultType.SuccessUnpublish, evtMsgs, content); - - // TODO: What is this check?? we just created this attempt and of course it is Success?! - if (attempt.Success == false) - { - return attempt; - } - - // if the document has any release dates set to before now, - // they should be removed so they don't interrupt an unpublish - // otherwise it would remain released == published - ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(content.Id); - IReadOnlyList pastReleases = - contentSchedule.GetPending(ContentScheduleAction.Expire, DateTime.UtcNow); - foreach (ContentSchedule p in pastReleases) - { - contentSchedule.Remove(p); - } - - if (pastReleases.Count > 0) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) had its release date removed, because it was unpublished.", content.Name, content.Id); - } - - _documentRepository.PersistContentSchedule(content, contentSchedule); - - // change state to unpublishing - content.PublishedState = PublishedState.Unpublishing; - - _logger.LogInformation("Document {ContentName} (id={ContentId}) has been unpublished.", content.Name, content.Id); - return attempt; - } - - #endregion - #region Content Types /// @@ -3593,48 +1812,6 @@ public void DeleteOfTypes(IEnumerable contentTypeIds, int userId = Constant public void DeleteOfType(int contentTypeId, int userId = Constants.Security.SuperUserId) => DeleteOfTypes(new[] { contentTypeId }, userId); - private IContentType GetContentType(ICoreScope scope, string contentTypeAlias) - { - if (contentTypeAlias == null) - { - throw new ArgumentNullException(nameof(contentTypeAlias)); - } - - if (string.IsNullOrWhiteSpace(contentTypeAlias)) - { - throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); - } - - scope.ReadLock(Constants.Locks.ContentTypes); - - IQuery query = Query().Where(x => x.Alias == contentTypeAlias); - IContentType? contentType = _contentTypeRepository.Get(query).FirstOrDefault() - ?? - // causes rollback - throw new Exception($"No ContentType matching the passed in Alias: '{contentTypeAlias}'" + - $" was found"); - - return contentType; - } - - private IContentType GetContentType(string contentTypeAlias) - { - if (contentTypeAlias == null) - { - throw new ArgumentNullException(nameof(contentTypeAlias)); - } - - if (string.IsNullOrWhiteSpace(contentTypeAlias)) - { - throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return GetContentType(scope, contentTypeAlias); - } - } - #endregion #region Blueprints @@ -3830,4 +2007,93 @@ public void DeleteBlueprintsOfType(int contentTypeId, int userId = Constants.Sec #endregion + #region Abstract implementations + + protected override UmbracoObjectTypes ContentObjectType => UmbracoObjectTypes.Document; + + protected override int[] ReadLockIds => WriteLockIds; + + protected override int[] WriteLockIds => new[] { Constants.Locks.ContentTree }; + + protected override bool SupportsBranchPublishing => true; + + protected override ILogger Logger => _logger; + + protected override IContent CreateContentInstance(string name, int parentId, IContentType contentType, int userId) + => new Content(name, parentId, contentType, userId); + + protected override IContent CreateContentInstance(string name, IContent parent, IContentType contentType, int userId) + => new Content(name, parent, contentType, userId); + + protected override void DeleteLocked(ICoreScope scope, IContent content, EventMessages evtMsgs) + { + void DoDelete(IContent c) + { + _documentRepository.Delete(c); + scope.Notifications.Publish(new ContentDeletedNotification(c, evtMsgs)); + + // media files deleted by QueuingEventDispatcher + } + + const int pageSize = 500; + var total = long.MaxValue; + while (total > 0) + { + // get descendants - ordered from deepest to shallowest + IEnumerable descendants = GetPagedDescendants(content.Id, 0, pageSize, out total, ordering: Ordering.By("Path", Direction.Descending)); + foreach (IContent c in descendants) + { + DoDelete(c); + } + } + + DoDelete(content); + } + + protected override SavingNotification SavingNotification(IContent content, EventMessages eventMessages) + => new ContentSavingNotification(content, eventMessages); + + protected override SavedNotification SavedNotification(IContent content, EventMessages eventMessages) + => new ContentSavedNotification(content, eventMessages); + + protected override SavingNotification SavingNotification(IEnumerable content, EventMessages eventMessages) + => new ContentSavingNotification(content, eventMessages); + + protected override SavedNotification SavedNotification(IEnumerable content, EventMessages eventMessages) + => new ContentSavedNotification(content, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IContent content, TreeChangeTypes changeTypes, EventMessages eventMessages) + => new ContentTreeChangeNotification(content, changeTypes, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IContent content, TreeChangeTypes changeTypes, IEnumerable? publishedCultures, IEnumerable? unpublishedCultures, EventMessages eventMessages) + => new ContentTreeChangeNotification(content, changeTypes, publishedCultures, unpublishedCultures, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IEnumerable content, TreeChangeTypes changeTypes, EventMessages eventMessages) + => new ContentTreeChangeNotification(content, changeTypes, eventMessages); + + protected override DeletingNotification DeletingNotification(IContent content, EventMessages eventMessages) + => new ContentDeletingNotification(content, eventMessages); + + protected override CancelableEnumerableObjectNotification PublishingNotification(IContent content, EventMessages eventMessages) + => new ContentPublishingNotification(content, eventMessages); + + protected override IStatefulNotification PublishedNotification(IContent content, EventMessages eventMessages) + => new ContentPublishedNotification(content, eventMessages); + + protected override IStatefulNotification PublishedNotification(IEnumerable content, EventMessages eventMessages) + => new ContentPublishedNotification(content, eventMessages); + + protected override CancelableEnumerableObjectNotification UnpublishingNotification(IContent content, EventMessages eventMessages) + => new ContentUnpublishingNotification(content, eventMessages); + + protected override IStatefulNotification UnpublishedNotification(IContent content, EventMessages eventMessages) + => new ContentUnpublishedNotification(content, eventMessages); + + protected override RollingBackNotification RollingBackNotification(IContent target, EventMessages messages) + => new ContentRollingBackNotification(target, messages); + + protected override RolledBackNotification RolledBackNotification(IContent target, EventMessages messages) + => new ContentRolledBackNotification(target, messages); + + #endregion } diff --git a/src/Umbraco.Core/Services/ContentTypeService.cs b/src/Umbraco.Core/Services/ContentTypeService.cs index dcd79468c929..20391b5bf9af 100644 --- a/src/Umbraco.Core/Services/ContentTypeService.cs +++ b/src/Umbraco.Core/Services/ContentTypeService.cs @@ -20,12 +20,15 @@ namespace Umbraco.Cms.Core.Services; public class ContentTypeService : ContentTypeServiceBase, IContentTypeService { private readonly ITemplateService _templateService; + private readonly IContentService _contentService; + private readonly IElementService _elementService; public ContentTypeService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, IContentService contentService, + IElementService elementService, IContentTypeRepository repository, IAuditService auditService, IDocumentTypeContainerRepository entityContainerRepository, @@ -47,7 +50,39 @@ public ContentTypeService( contentTypeFilters) { _templateService = templateService; - ContentService = contentService; + _contentService = contentService; + _elementService = elementService; + } + + [Obsolete("Use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")] + public ContentTypeService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IContentService contentService, + IContentTypeRepository repository, + IAuditService auditService, + IDocumentTypeContainerRepository entityContainerRepository, + IEntityRepository entityRepository, + IEventAggregator eventAggregator, + IUserIdKeyResolver userIdKeyResolver, + ContentTypeFilterCollection contentTypeFilters, + ITemplateService templateService) + : this( + provider, + loggerFactory, + eventMessagesFactory, + contentService, + StaticServiceProvider.Instance.GetRequiredService(), + repository, + auditService, + entityContainerRepository, + entityRepository, + eventAggregator, + userIdKeyResolver, + contentTypeFilters, + templateService) + { } [Obsolete("Use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")] @@ -169,14 +204,45 @@ public ContentTypeService( { } + [Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")] + public ContentTypeService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IContentService contentService, + IElementService elementService, + IContentTypeRepository repository, + IAuditRepository auditRepository, + IAuditService auditService, + IDocumentTypeContainerRepository entityContainerRepository, + IEntityRepository entityRepository, + IEventAggregator eventAggregator, + IUserIdKeyResolver userIdKeyResolver, + ContentTypeFilterCollection contentTypeFilters, + ITemplateService templateService) + : this( + provider, + loggerFactory, + eventMessagesFactory, + contentService, + elementService, + repository, + auditService, + entityContainerRepository, + entityRepository, + eventAggregator, + userIdKeyResolver, + contentTypeFilters, + templateService) + { + } + protected override int[] ReadLockIds => ContentTypeLocks.ReadLockIds; protected override int[] WriteLockIds => ContentTypeLocks.WriteLockIds; protected override Guid ContainedObjectType => Constants.ObjectTypes.DocumentType; - private IContentService ContentService { get; } - /// /// Gets all property type aliases across content, media and member types. /// @@ -281,8 +347,9 @@ protected override void DeleteItemsOfTypes(IEnumerable typeIds) using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { var typeIdsA = typeIds.ToArray(); - ContentService.DeleteOfTypes(typeIdsA); - ContentService.DeleteBlueprintsOfTypes(typeIdsA); + _contentService.DeleteOfTypes(typeIdsA); + _contentService.DeleteBlueprintsOfTypes(typeIdsA); + _elementService.DeleteOfTypes(typeIdsA); scope.Complete(); } } diff --git a/src/Umbraco.Core/Services/ContentVersionService.cs b/src/Umbraco.Core/Services/ContentVersionService.cs index 230aa2212623..f042744b1039 100644 --- a/src/Umbraco.Core/Services/ContentVersionService.cs +++ b/src/Umbraco.Core/Services/ContentVersionService.cs @@ -1,34 +1,18 @@ using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; -using Umbraco.Cms.Core.Extensions; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; -using Umbraco.Cms.Core.Services.OperationStatus; -using Umbraco.Cms.Core.Services.Pagination; -using Umbraco.Extensions; // ReSharper disable once CheckNamespace namespace Umbraco.Cms.Core.Services; -internal sealed class ContentVersionService : IContentVersionService +internal sealed class ContentVersionService : ContentVersionServiceBase, IContentVersionService { - private readonly IAuditService _auditService; - private readonly IContentVersionCleanupPolicy _contentVersionCleanupPolicy; - private readonly IDocumentVersionRepository _documentVersionRepository; - private readonly IEventMessagesFactory _eventMessagesFactory; - private readonly ILanguageRepository _languageRepository; - private readonly IEntityService _entityService; - private readonly IContentService _contentService; - private readonly IUserIdKeyResolver _userIdKeyResolver; - private readonly ILogger _logger; - private readonly ICoreScopeProvider _scopeProvider; - public ContentVersionService( ILogger logger, - IDocumentVersionRepository documentVersionRepository, + IDocumentVersionRepository contentVersionRepository, IContentVersionCleanupPolicy contentVersionCleanupPolicy, ICoreScopeProvider scopeProvider, IEventMessagesFactory eventMessagesFactory, @@ -37,282 +21,25 @@ public ContentVersionService( IEntityService entityService, IContentService contentService, IUserIdKeyResolver userIdKeyResolver) + : base( + logger, + contentVersionRepository, + contentVersionCleanupPolicy, + scopeProvider, + eventMessagesFactory, + auditService, + languageRepository, + entityService, + contentService, + userIdKeyResolver) { - _logger = logger; - _documentVersionRepository = documentVersionRepository; - _contentVersionCleanupPolicy = contentVersionCleanupPolicy; - _scopeProvider = scopeProvider; - _eventMessagesFactory = eventMessagesFactory; - _auditService = auditService; - _languageRepository = languageRepository; - _entityService = entityService; - _contentService = contentService; - _userIdKeyResolver = userIdKeyResolver; - } - - /// - public IReadOnlyCollection PerformContentVersionCleanup(DateTime asAtDate) => - - // Media - ignored - // Members - ignored - CleanupDocumentVersions(asAtDate); - - public ContentVersionMeta? Get(int versionId) - { - using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentVersionRepository.Get(versionId); - } - } - - public Task?, ContentVersionOperationStatus>> GetPagedContentVersionsAsync(Guid contentId, string? culture, int skip, int take) - { - IEntitySlim? document = _entityService.Get(contentId, UmbracoObjectTypes.Document); - if (document is null) - { - return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.ContentNotFound)); - } - - if (PaginationConverter.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize) == false) - { - return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.InvalidSkipTake)); - } - - IEnumerable versions = - HandleGetPagedContentVersions( - document.Id, - pageNumber, - pageSize, - out var total, - culture); - - return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Succeed( - ContentVersionOperationStatus.Success, new PagedModel(total, versions))); - } - - public Task> GetAsync(Guid versionId) - { - IContent? version = _contentService.GetVersion(versionId.ToInt()); - if (version is null) - { - return Task.FromResult(Attempt.Fail(ContentVersionOperationStatus.NotFound)); - } - - return Task.FromResult(Attempt.Succeed(ContentVersionOperationStatus.Success, version)); } - public async Task> SetPreventCleanupAsync(Guid versionId, bool preventCleanup, Guid userKey) - { - ContentVersionMeta? version = Get(versionId.ToInt()); - if (version is null) - { - return Attempt.Fail(ContentVersionOperationStatus.NotFound); - } - - HandleSetPreventCleanup(version.VersionId, preventCleanup, await _userIdKeyResolver.GetAsync(userKey)); - - return Attempt.Succeed(ContentVersionOperationStatus.Success); - } - - public async Task> RollBackAsync(Guid versionId, string? culture, Guid userKey) - { - ContentVersionMeta? version = Get(versionId.ToInt()); - if (version is null) - { - return Attempt.Fail(ContentVersionOperationStatus.NotFound); - } - - OperationResult rollBackResult = _contentService.Rollback( - version.ContentId, - version.VersionId, - culture ?? "*", - await _userIdKeyResolver.GetAsync(userKey)); - - if (rollBackResult.Success) - { - return Attempt.Succeed(ContentVersionOperationStatus.Success); - } - - switch (rollBackResult.Result) - { - case OperationResultType.Failed: - case OperationResultType.FailedCannot: - case OperationResultType.FailedExceptionThrown: - case OperationResultType.NoOperation: - default: - return Attempt.Fail(ContentVersionOperationStatus.RollBackFailed); - case OperationResultType.FailedCancelledByEvent: - return Attempt.Fail(ContentVersionOperationStatus.RollBackCanceled); - } - } - - private IEnumerable HandleGetPagedContentVersions( - int contentId, - long pageIndex, - int pageSize, - out long totalRecords, - string? culture = null) - { - using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) - { - var languageId = _languageRepository.GetIdByIsoCode(culture, true); - scope.ReadLock(Constants.Locks.ContentTree); - return _documentVersionRepository.GetPagedItemsByContentId(contentId, pageIndex, pageSize, out totalRecords, languageId); - } - } - - private void HandleSetPreventCleanup(int versionId, bool preventCleanup, int userId) - { - using (ICoreScope scope = _scopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - _documentVersionRepository.SetPreventCleanup(versionId, preventCleanup); + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Document; - ContentVersionMeta? version = _documentVersionRepository.Get(versionId); + protected override DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion) + => new ContentDeletingVersionsNotification(id, messages, specificVersion); - if (version is null) - { - scope.Complete(); - return; - } - - AuditType auditType = preventCleanup - ? AuditType.ContentVersionPreventCleanup - : AuditType.ContentVersionEnableCleanup; - - var message = $"set preventCleanup = '{preventCleanup}' for version '{versionId}'"; - - Audit(auditType, userId, version.ContentId, message, $"{version.VersionDate}"); - scope.Complete(); - } - } - - private IReadOnlyCollection CleanupDocumentVersions(DateTime asAtDate) - { - List versionsToDelete; - - /* Why so many scopes? - * - * We could just work out the set to delete at SQL infra level which was the original plan, however we agreed that really we should fire - * ContentService.DeletingVersions so people can hook & cancel if required. - * - * On first time run of cleanup on a site with a lot of history there may be a lot of historic ContentVersions to remove e.g. 200K for our.umbraco.com. - * If we weren't supporting SQL CE we could do TVP, or use temp tables to bulk delete with joins to our list of version ids to nuke. - * (much nicer, we can kill 100k in sub second time-frames). - * - * However we are supporting SQL CE, so the easiest thing to do is use the Umbraco InGroupsOf helper to create a query with 2K args of version - * ids to delete at a time. - * - * This is already done at the repository level, however if we only had a single scope at service level we're still locking - * the ContentVersions table (and other related tables) for a couple of minutes which makes the back office unusable. - * - * As a quick fix, we can also use InGroupsOf at service level, create a scope per group to give other connections a chance - * to grab the locks and execute their queries. - * - * This makes the back office a tiny bit sluggish during first run but it is usable for loading tree and publishing content. - * - * There are optimizations we can do, we could add a bulk delete for SqlServerSyntaxProvider which differs in implementation - * and fallback to this naive approach only for SQL CE, however we agreed it is not worth the effort as this is a one time pain, - * subsequent runs shouldn't have huge numbers of versions to cleanup. - * - * tl;dr lots of scopes to enable other connections to use the DB whilst we work. - */ - using (ICoreScope scope = _scopeProvider.CreateCoreScope()) - { - IReadOnlyCollection allHistoricVersions = - _documentVersionRepository.GetDocumentVersionsEligibleForCleanup(); - - if (allHistoricVersions.Count == 0) - { - scope.Complete(); - return Array.Empty(); - } - - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Discovered {count} candidate(s) for ContentVersion cleanup", allHistoricVersions.Count); - } - - versionsToDelete = new List(allHistoricVersions.Count); - - IEnumerable filteredContentVersions = - _contentVersionCleanupPolicy.Apply(asAtDate, allHistoricVersions); - - foreach (ContentVersionMeta version in filteredContentVersions) - { - EventMessages messages = _eventMessagesFactory.Get(); - - if (scope.Notifications.PublishCancelable( - new ContentDeletingVersionsNotification(version.ContentId, messages, version.VersionId))) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Delete cancelled for ContentVersion [{versionId}]", version.VersionId); - } - continue; - } - - versionsToDelete.Add(version); - } - - scope.Complete(); - } - - if (!versionsToDelete.Any()) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("No remaining ContentVersions for cleanup"); - } - return Array.Empty(); - } - - _logger.LogDebug("Removing {count} ContentVersion(s)", versionsToDelete.Count); - - foreach (IEnumerable group in versionsToDelete.InGroupsOf(Constants.Sql.MaxParameterCount)) - { - using (ICoreScope scope = _scopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - var groupEnumerated = group.ToList(); - _documentVersionRepository.DeleteVersions(groupEnumerated.Select(x => x.VersionId)); - - foreach (ContentVersionMeta version in groupEnumerated) - { - EventMessages messages = _eventMessagesFactory.Get(); - - scope.Notifications.Publish( - new ContentDeletedVersionsNotification(version.ContentId, messages, version.VersionId)); - } - - scope.Complete(); - } - } - - using (ICoreScope scope = _scopeProvider.CreateCoreScope()) - { - Audit(AuditType.Delete, Constants.Security.SuperUserId, -1, $"Removed {versionsToDelete.Count} ContentVersion(s) according to cleanup policy"); - - scope.Complete(); - } - - return versionsToDelete; - } - - private void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) => - AuditAsync(type, userId, objectId, message, parameters).GetAwaiter().GetResult(); - - private async Task AuditAsync(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) - { - Guid userKey = await _userIdKeyResolver.GetAsync(userId); - - await _auditService.AddAsync( - type, - userKey, - objectId, - UmbracoObjectTypes.Document.GetName(), - message, - parameters); - } + protected override DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion) + => new ContentDeletedVersionsNotification(id, messages, specificVersion); } diff --git a/src/Umbraco.Core/Services/ContentVersionServiceBase.cs b/src/Umbraco.Core/Services/ContentVersionServiceBase.cs new file mode 100644 index 000000000000..5654db7d6eb7 --- /dev/null +++ b/src/Umbraco.Core/Services/ContentVersionServiceBase.cs @@ -0,0 +1,322 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Extensions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Services.Pagination; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +internal abstract class ContentVersionServiceBase + where TContent : class, IPublishableContentBase +{ + private readonly IAuditService _auditService; + private readonly IContentVersionCleanupPolicy _contentVersionCleanupPolicy; + private readonly IContentVersionRepository _contentVersionRepository; + private readonly IEventMessagesFactory _eventMessagesFactory; + private readonly ILanguageRepository _languageRepository; + private readonly IEntityService _entityService; + private readonly IPublishableContentService _contentService; + private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly ILogger> _logger; + private readonly ICoreScopeProvider _scopeProvider; + + protected abstract UmbracoObjectTypes ItemObjectType { get; } + + public ContentVersionServiceBase( + ILogger> logger, + IContentVersionRepository contentVersionRepository, + IContentVersionCleanupPolicy contentVersionCleanupPolicy, + ICoreScopeProvider scopeProvider, + IEventMessagesFactory eventMessagesFactory, + IAuditService auditService, + ILanguageRepository languageRepository, + IEntityService entityService, + IPublishableContentService contentService, + IUserIdKeyResolver userIdKeyResolver) + { + _logger = logger; + _contentVersionRepository = contentVersionRepository; + _contentVersionCleanupPolicy = contentVersionCleanupPolicy; + _scopeProvider = scopeProvider; + _eventMessagesFactory = eventMessagesFactory; + _auditService = auditService; + _languageRepository = languageRepository; + _entityService = entityService; + _contentService = contentService; + _userIdKeyResolver = userIdKeyResolver; + } + + protected abstract DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion); + + protected abstract DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion); + + /// + public IReadOnlyCollection PerformContentVersionCleanup(DateTime asAtDate) => + + // Media - ignored + // Members - ignored + CleanupItemVersions(asAtDate); + + public ContentVersionMeta? Get(int versionId) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(Constants.Locks.ContentTree); + return _contentVersionRepository.Get(versionId); + } + } + + public Task?, ContentVersionOperationStatus>> GetPagedContentVersionsAsync(Guid contentId, string? culture, int skip, int take) + { + IEntitySlim? entity = _entityService.Get(contentId, ItemObjectType); + if (entity is null) + { + return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.ContentNotFound)); + } + + if (PaginationConverter.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize) == false) + { + return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.InvalidSkipTake)); + } + + IEnumerable versions = + HandleGetPagedContentVersions( + entity.Id, + pageNumber, + pageSize, + out var total, + culture); + + return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Succeed( + ContentVersionOperationStatus.Success, new PagedModel(total, versions))); + } + + public Task> GetAsync(Guid versionId) + { + TContent? version = _contentService.GetVersion(versionId.ToInt()); + if (version is null) + { + return Task.FromResult(Attempt.Fail(ContentVersionOperationStatus.NotFound)); + } + + return Task.FromResult(Attempt.Succeed(ContentVersionOperationStatus.Success, version)); + } + + public async Task> SetPreventCleanupAsync(Guid versionId, bool preventCleanup, Guid userKey) + { + ContentVersionMeta? version = Get(versionId.ToInt()); + if (version is null) + { + return Attempt.Fail(ContentVersionOperationStatus.NotFound); + } + + HandleSetPreventCleanup(version.VersionId, preventCleanup, await _userIdKeyResolver.GetAsync(userKey)); + + return Attempt.Succeed(ContentVersionOperationStatus.Success); + } + + public async Task> RollBackAsync(Guid versionId, string? culture, Guid userKey) + { + ContentVersionMeta? version = Get(versionId.ToInt()); + if (version is null) + { + return Attempt.Fail(ContentVersionOperationStatus.NotFound); + } + + OperationResult rollBackResult = _contentService.Rollback( + version.ContentId, + version.VersionId, + culture ?? "*", + await _userIdKeyResolver.GetAsync(userKey)); + + if (rollBackResult.Success) + { + return Attempt.Succeed(ContentVersionOperationStatus.Success); + } + + switch (rollBackResult.Result) + { + case OperationResultType.Failed: + case OperationResultType.FailedCannot: + case OperationResultType.FailedExceptionThrown: + case OperationResultType.NoOperation: + default: + return Attempt.Fail(ContentVersionOperationStatus.RollBackFailed); + case OperationResultType.FailedCancelledByEvent: + return Attempt.Fail(ContentVersionOperationStatus.RollBackCanceled); + } + } + + private IEnumerable HandleGetPagedContentVersions( + int contentId, + long pageIndex, + int pageSize, + out long totalRecords, + string? culture = null) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) + { + var languageId = _languageRepository.GetIdByIsoCode(culture, true); + scope.ReadLock(Constants.Locks.ContentTree); + return _contentVersionRepository.GetPagedItemsByContentId(contentId, pageIndex, pageSize, out totalRecords, languageId); + } + } + + private void HandleSetPreventCleanup(int versionId, bool preventCleanup, int userId) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + scope.WriteLock(Constants.Locks.ContentTree); + _contentVersionRepository.SetPreventCleanup(versionId, preventCleanup); + + ContentVersionMeta? version = _contentVersionRepository.Get(versionId); + + if (version is null) + { + scope.Complete(); + return; + } + + AuditType auditType = preventCleanup + ? AuditType.ContentVersionPreventCleanup + : AuditType.ContentVersionEnableCleanup; + + var message = $"set preventCleanup = '{preventCleanup}' for version '{versionId}'"; + + Audit(auditType, userId, version.ContentId, message, $"{version.VersionDate}"); + scope.Complete(); + } + } + + private IReadOnlyCollection CleanupItemVersions(DateTime asAtDate) + { + List versionsToDelete; + + /* Why so many scopes? + * + * We could just work out the set to delete at SQL infra level which was the original plan, however we agreed that really we should fire + * ContentService.DeletingVersions so people can hook & cancel if required. + * + * On first time run of cleanup on a site with a lot of history there may be a lot of historic ContentVersions to remove e.g. 200K for our.umbraco.com. + * If we weren't supporting SQL CE we could do TVP, or use temp tables to bulk delete with joins to our list of version ids to nuke. + * (much nicer, we can kill 100k in sub second time-frames). + * + * However we are supporting SQL CE, so the easiest thing to do is use the Umbraco InGroupsOf helper to create a query with 2K args of version + * ids to delete at a time. + * + * This is already done at the repository level, however if we only had a single scope at service level we're still locking + * the ContentVersions table (and other related tables) for a couple of minutes which makes the back office unusable. + * + * As a quick fix, we can also use InGroupsOf at service level, create a scope per group to give other connections a chance + * to grab the locks and execute their queries. + * + * This makes the back office a tiny bit sluggish during first run but it is usable for loading tree and publishing content. + * + * There are optimizations we can do, we could add a bulk delete for SqlServerSyntaxProvider which differs in implementation + * and fallback to this naive approach only for SQL CE, however we agreed it is not worth the effort as this is a one time pain, + * subsequent runs shouldn't have huge numbers of versions to cleanup. + * + * tl;dr lots of scopes to enable other connections to use the DB whilst we work. + */ + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + IReadOnlyCollection allHistoricVersions = + _contentVersionRepository.GetContentVersionsEligibleForCleanup(); + + if (allHistoricVersions.Count == 0) + { + scope.Complete(); + return Array.Empty(); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Discovered {count} candidate(s) for ContentVersion cleanup", allHistoricVersions.Count); + } + + versionsToDelete = new List(allHistoricVersions.Count); + + IEnumerable filteredContentVersions = + _contentVersionCleanupPolicy.Apply(asAtDate, allHistoricVersions); + + foreach (ContentVersionMeta version in filteredContentVersions) + { + EventMessages messages = _eventMessagesFactory.Get(); + + if (scope.Notifications.PublishCancelable(DeletingVersionsNotification(version.ContentId, messages, version.VersionId))) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Delete cancelled for ContentVersion [{versionId}]", version.VersionId); + } + continue; + } + + versionsToDelete.Add(version); + } + + scope.Complete(); + } + + if (!versionsToDelete.Any()) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("No remaining ContentVersions for cleanup"); + } + return Array.Empty(); + } + + _logger.LogDebug("Removing {count} ContentVersion(s)", versionsToDelete.Count); + + foreach (IEnumerable group in versionsToDelete.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + scope.WriteLock(Constants.Locks.ContentTree); + var groupEnumerated = group.ToList(); + _contentVersionRepository.DeleteVersions(groupEnumerated.Select(x => x.VersionId)); + + foreach (ContentVersionMeta version in groupEnumerated) + { + EventMessages messages = _eventMessagesFactory.Get(); + + scope.Notifications.Publish(DeletedVersionsNotification(version.ContentId, messages, version.VersionId)); + } + + scope.Complete(); + } + } + + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + Audit(AuditType.Delete, Constants.Security.SuperUserId, -1, $"Removed {versionsToDelete.Count} ContentVersion(s) according to cleanup policy"); + + scope.Complete(); + } + + return versionsToDelete; + } + + private void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) => + AuditAsync(type, userId, objectId, message, parameters).GetAwaiter().GetResult(); + + private async Task AuditAsync(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) + { + Guid userKey = await _userIdKeyResolver.GetAsync(userId); + + await _auditService.AddAsync( + type, + userKey, + objectId, + ItemObjectType.GetName(), + message, + parameters); + } +} diff --git a/src/Umbraco.Core/Services/CultureImpactFactory.cs b/src/Umbraco.Core/Services/CultureImpactFactory.cs index 3dd9b6b1f0b1..6225f51255b1 100644 --- a/src/Umbraco.Core/Services/CultureImpactFactory.cs +++ b/src/Umbraco.Core/Services/CultureImpactFactory.cs @@ -19,7 +19,7 @@ public CultureImpactFactory(IOptionsMonitor contentSettings) } /// - public CultureImpact? Create(string? culture, bool isDefault, IContent content) + public CultureImpact? Create(string? culture, bool isDefault, IContentBase content) { TryCreate(culture, isDefault, content.ContentType.Variations, true, _contentSettings.AllowEditInvariantFromNonDefault, out CultureImpact? impact); diff --git a/src/Umbraco.Core/Services/ElementContainerService.cs b/src/Umbraco.Core/Services/ElementContainerService.cs new file mode 100644 index 000000000000..19dce0bfb3c6 --- /dev/null +++ b/src/Umbraco.Core/Services/ElementContainerService.cs @@ -0,0 +1,393 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class ElementContainerService : EntityTypeContainerService, IElementContainerService +{ + private readonly IElementContainerRepository _entityContainerRepository; + private readonly IEntityService _entityService; + private readonly IElementRepository _elementRepository; + private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly IElementService _elementService; + private readonly ILogger _logger; + + // internal so the tests can reach it + internal const int DescendantsIteratorPageSize = 500; + + public ElementContainerService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IElementContainerRepository entityContainerRepository, + IAuditService auditService, + IEntityRepository entityRepository, + IUserIdKeyResolver userIdKeyResolver, + IEntityService entityService, + IElementRepository elementRepository, + IElementService elementService, + ILogger logger) + : base(provider, loggerFactory, eventMessagesFactory, entityContainerRepository, auditService, entityRepository, userIdKeyResolver) + { + _entityContainerRepository = entityContainerRepository; + _userIdKeyResolver = userIdKeyResolver; + _entityService = entityService; + _elementRepository = elementRepository; + _elementService = elementService; + _logger = logger; + } + + public async Task> MoveAsync(Guid key, Guid? parentKey, Guid userKey) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ElementTree); + + var parentId = Constants.System.Root; + var parentPath = parentId.ToString(); + var parentLevel = 0; + if (parentKey.HasValue && parentKey.Value != Guid.Empty) + { + EntityContainer? parent = _entityContainerRepository.Get(parentKey.Value); + if (parent is null) + { + return Attempt.FailWithStatus(EntityContainerOperationStatus.ParentNotFound, null); + } + + if (parent.Trashed) + { + // cannot move to a trashed container + return Attempt.FailWithStatus(EntityContainerOperationStatus.InTrash, null); + } + + parentId = parent.Id; + parentPath = parent.Path; + parentLevel = parent.Level; + } + + Attempt moveResult = await MoveLockedAsync( + scope, + key, + parentId, + parentPath, + parentLevel, + false, + userKey, + container => + { + if (parentPath.StartsWith(container.Path)) + { + // cannot move to descendant of self + return EntityContainerOperationStatus.InvalidParent; + } + + return EntityContainerOperationStatus.Success; + }, + (container, eventMessages) => + { + var moveEventInfo = new MoveEventInfo(container, container.Path, parentId, parentKey); + return new EntityContainerMovingNotification(moveEventInfo, eventMessages); + }, + (container, eventMessages) => + { + var moveEventInfo = new MoveEventInfo(container, container.Path, parentId, parentKey); + return new EntityContainerMovedNotification(moveEventInfo, eventMessages); + }); + + scope.Complete(); + return moveResult; + } + + public async Task> MoveToRecycleBinAsync(Guid key, Guid userKey) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ElementTree); + + var originalPath = string.Empty; + Attempt moveResult = await MoveLockedAsync( + scope, + key, + Constants.System.RecycleBinElement, + Constants.System.RecycleBinElementPathPrefix, + 0, + true, + userKey, + _ => EntityContainerOperationStatus.Success, + (container, eventMessages) => + { + originalPath = container.Path; + var moveEventInfo = new MoveToRecycleBinEventInfo(container, originalPath); + return new EntityContainerMovingToRecycleBinNotification(moveEventInfo, eventMessages); + }, + (container, eventMessages) => + { + var moveEventInfo = new MoveToRecycleBinEventInfo(container, originalPath); + return new EntityContainerMovedToRecycleBinNotification(moveEventInfo, eventMessages); + }); + + scope.Complete(); + return moveResult; + } + + public async Task> DeleteFromRecycleBinAsync(Guid key, Guid userKey) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ElementTree); + + Attempt deleteResult = await DeleteLockedAsync( + scope, + key, + userKey, + true); + + scope.Complete(); + return deleteResult; + } + + public async Task> EmptyRecycleBinAsync(Guid userKey) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ElementTree); + + EventMessages eventMessages = EventMessagesFactory.Get(); + + // fire the emptying notification and handle cancellation + var emptyingNotification = new ElementEmptyingRecycleBinNotification(eventMessages); + if (await scope.Notifications.PublishCancelableAsync(emptyingNotification)) + { + return Attempt.Fail(EntityContainerOperationStatus.CancelledByNotification); + } + + long total; + do + { + IEnumerable recycleBinRootItems = _entityService.GetPagedChildren( + Constants.System.RecycleBinElementKey, + [UmbracoObjectTypes.ElementContainer], + [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element], + 0, // pageIndex = 0 because we continuously delete items as we move through the descendants + DescendantsIteratorPageSize, + trashed: true, + out total); + + foreach (IEntitySlim recycleBinRootItem in recycleBinRootItems) + { + DeleteDescendantsLocked(recycleBinRootItem.Key); + + if (recycleBinRootItem.NodeObjectType == Constants.ObjectTypes.Element) + { + IElement? element = _elementRepository.Get(recycleBinRootItem.Key); + if (element is not null) + { + _elementRepository.Delete(element); + } + } + else + { + EntityContainer? container = await GetAsync(recycleBinRootItem.Key); + if (container is not null) + { + _entityContainerRepository.Delete(container); + } + } + } + } + while (total > DescendantsIteratorPageSize); + + await AuditAsync(AuditType.Delete, userKey, Constants.System.RecycleBinElement, "Recycle bin emptied"); + + // fire the deleted notification + scope.Notifications.Publish(new ElementEmptiedRecycleBinNotification(eventMessages).WithStateFrom(emptyingNotification)); + + scope.Complete(); + return Attempt.Succeed(EntityContainerOperationStatus.Success); + } + + private async Task> MoveLockedAsync( + ICoreScope scope, + Guid key, + int parentId, + string parentPath, + int parentLevel, + bool trash, + Guid userKey, + Func validateMove, + Func movingNotificationFactory, + Func movedNotificationFactory) + where TNotification : IStatefulNotification, ICancelableNotification + { + EntityContainer? container = _entityContainerRepository.Get(key); + if (container is null) + { + return Attempt.FailWithStatus(EntityContainerOperationStatus.NotFound, null); + } + + if (container.ParentId == parentId) + { + return Attempt.SucceedWithStatus(EntityContainerOperationStatus.Success, container); + } + + EntityContainerOperationStatus validateMoveResult = validateMove(container); + if (validateMoveResult != EntityContainerOperationStatus.Success) + { + return Attempt.FailWithStatus(validateMoveResult, null); + } + + EventMessages eventMessages = EventMessagesFactory.Get(); + + // fire the moving notification and handle cancellation + TNotification movingNotification = movingNotificationFactory(container, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(movingNotification)) + { + return Attempt.FailWithStatus(EntityContainerOperationStatus.CancelledByNotification, container); + } + + var newContainerPath = $"{parentPath.TrimEnd(Constants.CharArrays.Comma)},{container.Id}"; + var levelDelta = 1 - container.Level + parentLevel; + + long total; + + do + { + IEnumerable descendants = _entityService.GetPagedDescendants( + container.Key, + UmbracoObjectTypes.ElementContainer, + [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element], + 0, // pageIndex = 0 because the move operation is path based (starts-with), and we update paths as we move through the descendants + DescendantsIteratorPageSize, + out total); + + foreach (IEntitySlim descendant in descendants) + { + if (descendant.NodeObjectType == Constants.ObjectTypes.ElementContainer) + { + EntityContainer descendantContainer = _entityContainerRepository.Get(descendant.Id) + ?? throw new InvalidOperationException($"Descendant container with ID {descendant.Id} was not found."); + descendantContainer.Path = $"{newContainerPath}{descendant.Path[container.Path.Length..]}"; + descendantContainer.Level += levelDelta; + descendantContainer.Trashed = trash; + _entityContainerRepository.Save(descendantContainer); + } + else + { + IElement descendantElement = _elementRepository.Get(descendant.Id) + ?? throw new InvalidOperationException($"Descendant element with ID {descendant.Id} was not found."); + descendantElement.Path = $"{newContainerPath}{descendant.Path[container.Path.Length..]}"; + descendantElement.Level += levelDelta; + + // make sure the element is unpublished if it is moved from trash + var unpublishSuccess = await ElementEditingService.UnpublishTrashedElementOnRestore(descendantElement, userKey, _elementService, _userIdKeyResolver, _logger); + if (unpublishSuccess is false) + { + return Attempt.FailWithStatus(EntityContainerOperationStatus.Unknown, container); + } + + // NOTE: this cast isn't pretty, but it's the best we can do now. the content and media services do something + // similar, and at the time of writing this, we are subject to the limitations imposed there. + ((TreeEntityBase)descendantElement).Trashed = trash; + _elementRepository.Save(descendantElement); + } + } + } + while (total > DescendantsIteratorPageSize); + + // NOTE: as long as the parent ID is correct, the container repo takes care of updating the rest of the + // structural node data like path, level, sort orders etc. + container.ParentId = parentId; + container.Trashed = trash; + + _entityContainerRepository.Save(container); + + await AuditAsync(AuditType.Move, userKey, container.Id); + + // fire the moved notification + IStatefulNotification movedNotification = movedNotificationFactory(container, eventMessages); + scope.Notifications.Publish(movedNotification.WithStateFrom(movingNotification)); + + return Attempt.SucceedWithStatus(EntityContainerOperationStatus.Success, container); + } + + private async Task> DeleteLockedAsync( + ICoreScope scope, + Guid key, + Guid userKey, + bool mustBeTrashed) + { + EntityContainer? container = _entityContainerRepository.Get(key); + if (container is null) + { + return Attempt.FailWithStatus(EntityContainerOperationStatus.NotFound, null); + } + + if (mustBeTrashed && container.Trashed is false) + { + return Attempt.FailWithStatus(EntityContainerOperationStatus.NotInTrash, container); + } + + EventMessages eventMessages = EventMessagesFactory.Get(); + + // fire the deleting notification and handle cancellation + var deletingNotification = new EntityContainerDeletingNotification(container, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(deletingNotification)) + { + return Attempt.FailWithStatus(EntityContainerOperationStatus.CancelledByNotification, container); + } + + DeleteDescendantsLocked(container.Key); + + _entityContainerRepository.Delete(container); + + await AuditAsync(AuditType.Delete, userKey, container.Id); + + // fire the deleted notification + scope.Notifications.Publish(new EntityContainerDeletedNotification(container, eventMessages).WithStateFrom(deletingNotification)); + + return Attempt.SucceedWithStatus(EntityContainerOperationStatus.Success, container); + } + + private void DeleteDescendantsLocked(Guid key) + { + long total; + + do + { + IEnumerable descendants = _entityService.GetPagedDescendants( + key, + UmbracoObjectTypes.ElementContainer, + [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element], + 0, // pageIndex = 0 because we continuously delete items as we move through the descendants + DescendantsIteratorPageSize, + out total); + + foreach (IEntitySlim descendant in descendants) + { + if (descendant.NodeObjectType == Constants.ObjectTypes.ElementContainer) + { + EntityContainer descendantContainer = _entityContainerRepository.Get(descendant.Id) + ?? throw new InvalidOperationException($"Descendant container with ID {descendant.Id} was not found."); + _entityContainerRepository.Delete(descendantContainer); + } + else + { + IElement descendantElement = _elementRepository.Get(descendant.Id) + ?? throw new InvalidOperationException($"Descendant element with ID {descendant.Id} was not found."); + _elementRepository.Delete(descendantElement); + } + } + } + while (total > DescendantsIteratorPageSize); + } + + protected override Guid ContainedObjectType => Constants.ObjectTypes.Element; + + protected override UmbracoObjectTypes ContainerObjectType => UmbracoObjectTypes.ElementContainer; + + protected override int[] ReadLockIds => new [] { Constants.Locks.ElementTree }; + + protected override int[] WriteLockIds => ReadLockIds; +} diff --git a/src/Umbraco.Core/Services/ElementEditingService.cs b/src/Umbraco.Core/Services/ElementEditingService.cs new file mode 100644 index 000000000000..c6cd1a73a02b --- /dev/null +++ b/src/Umbraco.Core/Services/ElementEditingService.cs @@ -0,0 +1,397 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.Filters; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class ElementEditingService + : ContentEditingServiceBase, IElementEditingService +{ + private readonly ILogger _logger; + private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly IElementContainerService _containerService; + private readonly IEventMessagesFactory _eventMessagesFactory; + private readonly IIdKeyMap _idKeyMap; + + public ElementEditingService( + IElementService elementService, + IContentTypeService contentTypeService, + PropertyEditorCollection propertyEditorCollection, + IDataTypeService dataTypeService, + ILogger logger, + ICoreScopeProvider scopeProvider, + IUserIdKeyResolver userIdKeyResolver, + IElementValidationService validationService, + IOptionsMonitor optionsMonitor, + IRelationService relationService, + IElementContainerService containerService, + ContentTypeFilterCollection contentTypeFilters, + IEventMessagesFactory eventMessagesFactory, + IIdKeyMap idKeyMap) + : base( + elementService, + contentTypeService, + propertyEditorCollection, + dataTypeService, + logger, + scopeProvider, + userIdKeyResolver, + validationService, + optionsMonitor, + relationService, + contentTypeFilters) + { + _logger = logger; + _userIdKeyResolver = userIdKeyResolver; + _containerService = containerService; + _eventMessagesFactory = eventMessagesFactory; + _idKeyMap = idKeyMap; + } + + public Task GetAsync(Guid key) + { + IElement? element = ContentService.GetById(key); + return Task.FromResult(element); + } + + // TODO ELEMENTS: implement validation here + public Task> ValidateCreateAsync(ElementCreateModel createModel, Guid userKey) + => Task.FromResult(Attempt.Succeed(ContentEditingOperationStatus.Success, new ())); + + // TODO ELEMENTS: implement validation here + public Task> ValidateUpdateAsync(Guid key, ValidateElementUpdateModel updateModel, Guid userKey) + => Task.FromResult(Attempt.Succeed(ContentEditingOperationStatus.Success, new ())); + + public async Task> CreateAsync(ElementCreateModel createModel, Guid userKey) + { + if (await ValidateCulturesAsync(createModel) is false) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.InvalidCulture, new ElementCreateResult()); + } + + Attempt result = await MapCreate(createModel); + if (result.Success == false) + { + return result; + } + + // the create mapping might succeed, but this doesn't mean the model is valid at property level. + // we'll return the actual property validation status if the entire operation succeeds. + ContentEditingOperationStatus validationStatus = result.Status; + ContentValidationResult validationResult = result.Result.ValidationResult; + + // TODO ELEMENTS: we need a fix for this; see ContentEditingService + IElement element = result.Result.Content!; + // IElement element = await EnsureOnlyAllowedFieldsAreUpdated(result.Result.Content!, userKey); + + ContentEditingOperationStatus saveStatus = await SaveAsync(element, userKey); + return saveStatus == ContentEditingOperationStatus.Success + ? Attempt.SucceedWithStatus(validationStatus, new ElementCreateResult { Content = element, ValidationResult = validationResult }) + : Attempt.FailWithStatus(saveStatus, new ElementCreateResult { Content = element }); + } + + public async Task> UpdateAsync(Guid key, ElementUpdateModel updateModel, Guid userKey) + { + IElement? element = ContentService.GetById(key); + if (element is null) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ElementUpdateResult()); + } + + if (await ValidateCulturesAsync(updateModel) is false) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.InvalidCulture, new ElementUpdateResult { Content = element }); + } + + Attempt result = await MapUpdate(element, updateModel); + if (result.Success == false) + { + return Attempt.FailWithStatus(result.Status, result.Result); + } + + // the update mapping might succeed, but this doesn't mean the model is valid at property level. + // we'll return the actual property validation status if the entire operation succeeds. + ContentEditingOperationStatus validationStatus = result.Status; + ContentValidationResult validationResult = result.Result.ValidationResult; + + // TODO ELEMENTS: we need a fix for this; see ContentEditingService + // element = await EnsureOnlyAllowedFieldsAreUpdated(element, userKey); + + ContentEditingOperationStatus saveStatus = await SaveAsync(element, userKey); + return saveStatus == ContentEditingOperationStatus.Success + ? Attempt.SucceedWithStatus(validationStatus, new ElementUpdateResult { Content = element, ValidationResult = validationResult }) + : Attempt.FailWithStatus(saveStatus, new ElementUpdateResult { Content = element }); + } + + public async Task> DeleteAsync(Guid key, Guid userKey) + => await HandleDeleteAsync(key, userKey, false); + + public async Task> DeleteFromRecycleBinAsync(Guid key, Guid userKey) + => await HandleDeleteAsync(key, userKey, true); + + protected override IElement New(string? name, int parentId, IContentType contentType) + => new Element(name, parentId, contentType); + + protected override async Task<(int? ParentId, ContentEditingOperationStatus OperationStatus)> TryGetAndValidateParentIdAsync(Guid? parentKey, IContentType contentType) + { + if (parentKey.HasValue is false) + { + return (Constants.System.Root, ContentEditingOperationStatus.Success); + } + + EntityContainer? container = await _containerService.GetAsync(parentKey.Value); + return container is not null + ? (container.Id, ContentEditingOperationStatus.Success) + : (null, ContentEditingOperationStatus.ParentNotFound); + } + + public async Task> MoveAsync(Guid key, Guid? containerKey, Guid userKey) + { + using ICoreScope scope = CoreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ElementTree); + + var parentId = Constants.System.Root; + if (containerKey.HasValue && containerKey.Value != Guid.Empty) + { + EntityContainer? container = await _containerService.GetAsync(containerKey.Value); + if (container is null) + { + return Attempt.Fail(ContentEditingOperationStatus.ParentNotFound); + } + + if (container.Trashed) + { + // cannot move to a trashed container + return Attempt.Fail(ContentEditingOperationStatus.InTrash); + } + + parentId = container.Id; + } + + Attempt moveResult = await MoveLockedAsync( + scope, + key, + parentId, + false, + userKey, + (element, eventMessages) => + { + var moveEventInfo = new MoveEventInfo(element, element.Path, parentId, containerKey); + return new ElementMovingNotification(moveEventInfo, eventMessages); + }, + (element, eventMessages) => + { + var moveEventInfo = new MoveEventInfo(element, element.Path, parentId, containerKey); + return new ElementMovedNotification(moveEventInfo, eventMessages); + }); + + scope.Complete(); + + return moveResult; + } + + public async Task> MoveToRecycleBinAsync(Guid key, Guid userKey) + { + using ICoreScope scope = CoreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ElementTree); + + var originalPath = string.Empty; + Attempt moveResult = await MoveLockedAsync( + scope, + key, + Constants.System.RecycleBinElement, + true, + userKey, + (element, eventMessages) => + { + originalPath = element.Path; + var moveEventInfo = new MoveToRecycleBinEventInfo(element, originalPath); + return new ElementMovingToRecycleBinNotification(moveEventInfo, eventMessages); + }, + (element, eventMessages) => + { + var moveEventInfo = new MoveToRecycleBinEventInfo(element, originalPath); + return new ElementMovedToRecycleBinNotification(moveEventInfo, eventMessages); + }); + + scope.Complete(); + + return moveResult; + } + + public async Task> CopyAsync(Guid key, Guid? parentKey, Guid userKey) + => await HandleCopyAsync(key, parentKey, false, false, userKey); + + internal static async Task UnpublishTrashedElementOnRestore(IElement element, Guid userKey, IElementService elementService, IUserIdKeyResolver userIdKeyResolver, ILogger logger) + { + // this only applies to trashed, published elements + if (element is not { Trashed: true, Published: true }) + { + return true; + } + + var userId = await userIdKeyResolver.GetAsync(userKey); + PublishResult result = elementService.Unpublish(element, "*", userId); + + // we will accept if custom code cancels the unpublish operation here - all other error states should + // result in a failed move. + if (result.Success is false && result.Result is not PublishResultType.FailedUnpublishCancelledByEvent) + { + logger.LogError("An error occurred while attempting to unpublish an element being moved from the recycle bin. Status was: {unpublishStatus}", result.Result); + return false; + } + + return true; + } + + private async Task> MoveLockedAsync( + ICoreScope scope, + Guid key, + int parentId, + bool trash, + Guid userKey, + Func movingNotificationFactory, + Func movedNotificationFactory) + where TNotification : IStatefulNotification, ICancelableNotification + { + IElement? toMove = await GetAsync(key); + if (toMove is null) + { + return Attempt.Fail(ContentEditingOperationStatus.NotFound); + } + + if (toMove.ParentId == parentId) + { + return Attempt.Succeed(ContentEditingOperationStatus.Success); + } + + EventMessages eventMessages = _eventMessagesFactory.Get(); + + TNotification movingNotification = movingNotificationFactory(toMove, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(movingNotification)) + { + return Attempt.Fail(ContentEditingOperationStatus.CancelledByNotification); + } + + var unpublishSuccess = await UnpublishTrashedElementOnRestore(toMove, userKey, ContentService, _userIdKeyResolver, _logger); + if (unpublishSuccess is false) + { + return Attempt.Fail(ContentEditingOperationStatus.Unknown); + } + + // NOTE: as long as the parent ID is correct, the element repo takes care of updating the rest of the + // structural node data like path, level, sort orders etc. + toMove.ParentId = parentId; + + // NOTE: this cast isn't pretty, but it's the best we can do now. the content and media services do something + // similar, and at the time of writing this, we are subject to the limitations imposed there. + ((TreeEntityBase)toMove).Trashed = trash; + + ContentEditingOperationStatus saveResult = await SaveAsync(toMove, userKey); + if (saveResult is not ContentEditingOperationStatus.Success) + { + return Attempt.Fail(saveResult); + } + + IStatefulNotification movedNotification = movedNotificationFactory(toMove, eventMessages); + scope.Notifications.Publish(movedNotification.WithStateFrom(movingNotification)); + + return Attempt.Succeed(ContentEditingOperationStatus.Success); + } + + protected override IElement? Copy(IElement element, int newParentId, bool relateToOriginal, bool includeDescendants, int userId) + { + Guid? newParentKey; + if (newParentId is Constants.System.Root) + { + newParentKey = Constants.System.RootKey; + } + else + { + Attempt parentKeyAttempt = _idKeyMap.GetKeyForId(newParentId, UmbracoObjectTypes.ElementContainer); + if (parentKeyAttempt.Success is false) + { + return null; + } + + newParentKey = parentKeyAttempt.Result; + } + + using ICoreScope scope = CoreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ElementTree); + + EventMessages eventMessages = _eventMessagesFactory.Get(); + + IElement copy = element.DeepCloneWithResetIdentities(); + copy.ParentId = newParentId; + + var copyingNotification = new ElementCopyingNotification(element, copy, newParentId, newParentKey, eventMessages); + if (scope.Notifications.PublishCancelable(copyingNotification)) + { + scope.Complete(); + return null; + } + + // update published state (copies cannot be published) + copy.Published = false; + + // update creator and writer IDs + copy.CreatorId = userId; + copy.WriterId = userId; + + OperationResult saveResult = ContentService.Save(copy, userId); + if (saveResult.Success is false) + { + return null; + } + + scope.Notifications.Publish( + new ElementCopiedNotification(element, copy, newParentId, newParentKey, relateToOriginal, eventMessages) + .WithStateFrom(copyingNotification)); + + scope.Complete(); + + return copy; + } + + protected override OperationResult? MoveToRecycleBin(IElement element, int userId) + => throw new NotImplementedException("TODO ELEMENTS: implement recycle bin"); + + protected override OperationResult? Delete(IElement element, int userId) + => ContentService.Delete(element, userId); + + // NOTE: We have a custom implementation for Move because ContentEditingServiceBase has no concept of Containers. + protected override OperationResult? Move(IElement element, int newParentId, int userId) => throw new NotImplementedException(); + + private async Task SaveAsync(IElement content, Guid userKey) + { + try + { + var currentUserId = await GetUserIdAsync(userKey); + OperationResult saveResult = ContentService.Save(content, currentUserId); + return saveResult.Result switch + { + // these are the only result states currently expected from Save + OperationResultType.Success => ContentEditingOperationStatus.Success, + OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification, + + // for any other state we'll return "unknown" so we know that we need to amend this + _ => ContentEditingOperationStatus.Unknown + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Content save operation failed"); + return ContentEditingOperationStatus.Unknown; + } + } +} diff --git a/src/Umbraco.Core/Services/ElementPermissionService.cs b/src/Umbraco.Core/Services/ElementPermissionService.cs new file mode 100644 index 000000000000..6f9175deb5c6 --- /dev/null +++ b/src/Umbraco.Core/Services/ElementPermissionService.cs @@ -0,0 +1,192 @@ +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.AuthorizationStatus; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +/// +internal sealed class ElementPermissionService : IElementPermissionService +{ + private readonly IEntityService _entityService; + private readonly IUserService _userService; + private readonly AppCaches _appCaches; + private readonly ILanguageService _languageService; + + public ElementPermissionService( + IEntityService entityService, + IUserService userService, + AppCaches appCaches, + ILanguageService languageService) + { + _entityService = entityService; + _userService = userService; + _appCaches = appCaches; + _languageService = languageService; + } + + /// + public Task AuthorizeAccessAsync( + IUser user, + IEnumerable elementKeys, + ISet permissionsToCheck) + { + Guid[] keys = elementKeys.ToArray(); + if (keys.Length == 0) + { + return Task.FromResult(ElementAuthorizationStatus.NotFound); + } + + // Fetch both Elements and ElementContainers (folders) + IEntitySlim[] entities = _entityService.GetAll( + new[] { UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer }, + keys).ToArray(); + + if (entities.Length == 0) + { + return Task.FromResult(ElementAuthorizationStatus.NotFound); + } + + if (entities.Any(entity => user.HasElementPathAccess(entity, _entityService, _appCaches) == false)) + { + return Task.FromResult(ElementAuthorizationStatus.UnauthorizedMissingPathAccess); + } + + return Task.FromResult( + HasPermissionAccess(user, entities.Select(e => e.Path), permissionsToCheck) + ? ElementAuthorizationStatus.Success + : ElementAuthorizationStatus.UnauthorizedMissingPermissionAccess); + } + + /// + public Task AuthorizeDescendantsAccessAsync( + IUser user, + Guid parentKey, + ISet permissionsToCheck) + { + var denied = new List(); + var skip = 0; + const int take = 500; + var total = long.MaxValue; + + UmbracoObjectTypes[] objectTypes = { UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer }; + + // Try to find the parent as either Element or ElementContainer + IEntitySlim? parentEntity = _entityService.GetAll(objectTypes, parentKey).FirstOrDefault(); + + if (parentEntity is null) + { + return Task.FromResult(ElementAuthorizationStatus.NotFound); + } + + UmbracoObjectTypes parentObjectType = ObjectTypes.GetUmbracoObjectType(parentEntity.NodeObjectType); + + while (skip < total) + { + // Order descendants by shallowest to deepest, this allows us to check permissions from top to bottom, + // so we can exit early if a permission higher up fails. + IEnumerable descendants = _entityService.GetPagedDescendants( + parentKey, + parentObjectType, + objectTypes, + skip, + take, + out total, + ordering: Ordering.By("path")); + + skip += take; + + foreach (IEntitySlim descendant in descendants) + { + var hasPathAccess = user.HasElementPathAccess(descendant, _entityService, _appCaches); + var hasPermissionAccess = HasPermissionAccess(user, new[] { descendant.Path }, permissionsToCheck); + + // If this item's path has already been denied or if the user doesn't have access to it, add to the deny list. + if (denied.Any(x => descendant.Path.StartsWith($"{x.Path},")) || hasPathAccess == false || hasPermissionAccess == false) + { + denied.Add(descendant); + } + } + } + + return Task.FromResult(denied.Count == 0 + ? ElementAuthorizationStatus.Success + : ElementAuthorizationStatus.UnauthorizedMissingDescendantAccess); + } + + /// + public Task AuthorizeRootAccessAsync(IUser user, ISet permissionsToCheck) + { + var hasAccess = user.HasElementRootAccess(_entityService, _appCaches); + + if (hasAccess == false) + { + return Task.FromResult(ElementAuthorizationStatus.UnauthorizedMissingRootAccess); + } + + // In this case, we have to use the Root id as path (i.e. -1) since we don't have an element item + return Task.FromResult(HasPermissionAccess(user, new[] { Constants.System.RootString }, permissionsToCheck) + ? ElementAuthorizationStatus.Success + : ElementAuthorizationStatus.UnauthorizedMissingPermissionAccess); + } + + /// + public Task AuthorizeBinAccessAsync(IUser user, ISet permissionsToCheck) + { + var hasAccess = user.HasElementBinAccess(_entityService, _appCaches); + + if (hasAccess == false) + { + return Task.FromResult(ElementAuthorizationStatus.UnauthorizedMissingBinAccess); + } + + // In this case, we have to use the Recycle Bin id as path (i.e. -22) since we don't have an element item + return Task.FromResult(HasPermissionAccess(user, new[] { Constants.System.RecycleBinElementString }, permissionsToCheck) + ? ElementAuthorizationStatus.Success + : ElementAuthorizationStatus.UnauthorizedMissingPermissionAccess); + } + + /// + public async Task AuthorizeCultureAccessAsync(IUser user, ISet culturesToCheck) + { + if (user.Groups.Any(group => group.HasAccessToAllLanguages)) + { + return ElementAuthorizationStatus.Success; + } + + var allowedLanguages = user.Groups.SelectMany(g => g.AllowedLanguages).Distinct().ToArray(); + var allowedLanguageIsoCodes = await _languageService.GetIsoCodesByIdsAsync(allowedLanguages); + + return culturesToCheck.All(culture => allowedLanguageIsoCodes.InvariantContains(culture)) + ? ElementAuthorizationStatus.Success + : ElementAuthorizationStatus.UnauthorizedMissingCulture; + } + + /// + /// Check the implicit/inherited permissions of a user for given element items. + /// + /// to check for access. + /// The paths of the element items to check for access. + /// The permissions to authorize. + /// true if the user has the required permissions; otherwise, false. + private bool HasPermissionAccess(IUser user, IEnumerable elementPaths, ISet permissionsToCheck) + { + foreach (var path in elementPaths) + { + // get the implicit/inherited permissions for the user for this path + EntityPermissionSet permissionSet = _userService.GetPermissionsForPath(user, path); + + foreach (var p in permissionsToCheck) + { + if (permissionSet.GetAllPermissions().Contains(p) == false) + { + return false; + } + } + } + + return true; + } +} diff --git a/src/Umbraco.Core/Services/ElementPublishingService.cs b/src/Umbraco.Core/Services/ElementPublishingService.cs new file mode 100644 index 000000000000..7f671b4df587 --- /dev/null +++ b/src/Umbraco.Core/Services/ElementPublishingService.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class ElementPublishingService : ContentPublishingServiceBase, IElementPublishingService +{ + public ElementPublishingService( + ICoreScopeProvider coreScopeProvider, + IElementService contentService, + IUserIdKeyResolver userIdKeyResolver, + IContentValidationService contentValidationService, + IContentTypeService contentTypeService, + ILanguageService languageService, + IOptionsMonitor optionsMonitor, + IRelationService relationService, + ILogger> logger) + : base( + coreScopeProvider, + contentService, + userIdKeyResolver, + contentValidationService, + contentTypeService, + languageService, + optionsMonitor, + relationService, + logger) + { + } + + protected override int WriteLockId => Constants.Locks.ElementTree; +} diff --git a/src/Umbraco.Core/Services/ElementService.cs b/src/Umbraco.Core/Services/ElementService.cs new file mode 100644 index 000000000000..e706113b30a7 --- /dev/null +++ b/src/Umbraco.Core/Services/ElementService.cs @@ -0,0 +1,213 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Core.Services; + +public class ElementService : PublishableContentServiceBase, IElementService +{ + private readonly IElementRepository _elementRepository; + private readonly ILogger _logger; + private readonly IShortStringHelper _shortStringHelper; + + public ElementService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IAuditService auditService, + IContentTypeRepository contentTypeRepository, + IElementRepository elementRepository, + ILanguageRepository languageRepository, + Lazy propertyValidationService, + ICultureImpactFactory cultureImpactFactory, + IUserIdKeyResolver userIdKeyResolver, + PropertyEditorCollection propertyEditorCollection, + IIdKeyMap idKeyMap, + IShortStringHelper shortStringHelper) + : base( + provider, + loggerFactory, + eventMessagesFactory, + auditService, + contentTypeRepository, + elementRepository, + languageRepository, + propertyValidationService, + cultureImpactFactory, + userIdKeyResolver, + propertyEditorCollection, + idKeyMap) + { + _elementRepository = elementRepository; + _shortStringHelper = shortStringHelper; + _logger = loggerFactory.CreateLogger(); + } + + #region Create + + public IElement Create(string name, string contentTypeAlias, int userId = Constants.Security.SuperUserId) + { + IContentType contentType = GetContentType(contentTypeAlias) + // causes rollback + ?? throw new ArgumentException("No content type with that alias.", nameof(contentTypeAlias)); + + var element = new Element(name, contentType, userId); + + return element; + } + + #endregion + + #region Others + + // TODO ELEMENTS: create abstractions of the implementations in this region, and share them with ContentService + + Attempt IContentServiceBase.Save(IEnumerable contents, int userId) => + Attempt.Succeed(Save(contents, userId)); + + public ContentDataIntegrityReport CheckDataIntegrity(ContentDataIntegrityReportOptions options) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(Constants.Locks.ContentTree); + + ContentDataIntegrityReport report = _elementRepository.CheckDataIntegrity(options); + + if (report.FixedIssues.Count > 0) + { + // The event args needs a content item so we'll make a fake one with enough properties to not cause a null ref + var root = new Element("root", -1, new ContentType(_shortStringHelper, -1)) { Id = -1, Key = Guid.Empty }; + scope.Notifications.Publish(new ElementTreeChangeNotification(root, TreeChangeTypes.RefreshAll, EventMessagesFactory.Get())); + } + + scope.Complete(); + + return report; + } + } + + #endregion + + #region Content Types + + /// + public void DeleteOfTypes(IEnumerable contentTypeIds, int userId = Constants.Security.SuperUserId) + { + var changes = new List>(); + var contentTypeIdsA = contentTypeIds.ToArray(); + EventMessages eventMessages = EventMessagesFactory.Get(); + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + scope.WriteLock(WriteLockIds); + + IQuery query = Query().WhereIn(x => x.ContentTypeId, contentTypeIdsA); + IElement[] elements = _elementRepository.Get(query).ToArray(); + + if (elements.Length is 0) + { + scope.Complete(); + return; + } + + if (scope.Notifications.PublishCancelable(new ElementDeletingNotification(elements, eventMessages))) + { + scope.Complete(); + return; + } + + foreach (IElement element in elements) + { + // delete content + // triggers the deleted event + DeleteLocked(scope, element, eventMessages); + changes.Add(new TreeChange(element, TreeChangeTypes.Remove)); + } + + scope.Notifications.Publish(new ElementTreeChangeNotification(changes, eventMessages)); + + Audit(AuditType.Delete, userId, Constants.System.Root, $"Delete element of type {string.Join(",", contentTypeIdsA)}"); + + scope.Complete(); + } + + #endregion + + #region Abstract implementations + + protected override UmbracoObjectTypes ContentObjectType => UmbracoObjectTypes.Element; + + protected override int[] ReadLockIds => WriteLockIds; + + protected override int[] WriteLockIds => new[] { Constants.Locks.ElementTree }; + + protected override bool SupportsBranchPublishing => false; + + protected override ILogger Logger => _logger; + + protected override IElement CreateContentInstance(string name, int parentId, IContentType contentType, int userId) + => new Element(name, contentType, userId); + + // TODO ELEMENTS: this should only be on the content service + protected override IElement CreateContentInstance(string name, IElement parent, IContentType contentType, int userId) + => throw new InvalidOperationException("Elements cannot be nested underneath one another"); + + protected override void DeleteLocked(ICoreScope scope, IElement content, EventMessages evtMsgs) + { + _elementRepository.Delete(content); + scope.Notifications.Publish(new ElementDeletedNotification(content, evtMsgs)); + } + + protected override SavingNotification SavingNotification(IElement content, EventMessages eventMessages) + => new ElementSavingNotification(content, eventMessages); + + protected override SavedNotification SavedNotification(IElement content, EventMessages eventMessages) + => new ElementSavedNotification(content, eventMessages); + + protected override SavingNotification SavingNotification(IEnumerable content, EventMessages eventMessages) + => new ElementSavingNotification(content, eventMessages); + + protected override SavedNotification SavedNotification(IEnumerable content, EventMessages eventMessages) + => new ElementSavedNotification(content, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IElement content, TreeChangeTypes changeTypes, EventMessages eventMessages) + => new ElementTreeChangeNotification(content, changeTypes, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IElement content, TreeChangeTypes changeTypes, IEnumerable? publishedCultures, IEnumerable? unpublishedCultures, EventMessages eventMessages) + => new ElementTreeChangeNotification(content, changeTypes, publishedCultures, unpublishedCultures, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IEnumerable content, TreeChangeTypes changeTypes, EventMessages eventMessages) + => new ElementTreeChangeNotification(content, changeTypes, eventMessages); + + protected override DeletingNotification DeletingNotification(IElement content, EventMessages eventMessages) + => new ElementDeletingNotification(content, eventMessages); + + protected override CancelableEnumerableObjectNotification PublishingNotification(IElement content, EventMessages eventMessages) + => new ElementPublishingNotification(content, eventMessages); + + protected override IStatefulNotification PublishedNotification(IElement content, EventMessages eventMessages) + => new ElementPublishedNotification(content, eventMessages); + + protected override IStatefulNotification PublishedNotification(IEnumerable content, EventMessages eventMessages) + => new ElementPublishedNotification(content, eventMessages); + + protected override CancelableEnumerableObjectNotification UnpublishingNotification(IElement content, EventMessages eventMessages) + => new ElementUnpublishingNotification(content, eventMessages); + + protected override IStatefulNotification UnpublishedNotification(IElement content, EventMessages eventMessages) + => new ElementUnpublishedNotification(content, eventMessages); + + protected override RollingBackNotification RollingBackNotification(IElement target, EventMessages messages) + => new ElementRollingBackNotification(target, messages); + + protected override RolledBackNotification RolledBackNotification(IElement target, EventMessages messages) + => new ElementRolledBackNotification(target, messages); + + #endregion +} diff --git a/src/Umbraco.Core/Services/ElementValidationService.cs b/src/Umbraco.Core/Services/ElementValidationService.cs new file mode 100644 index 000000000000..98b3523fce0b --- /dev/null +++ b/src/Umbraco.Core/Services/ElementValidationService.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class ElementValidationService : ContentValidationServiceBase, IElementValidationService +{ + public ElementValidationService(IPropertyValidationService propertyValidationService, ILanguageService languageService) + : base(propertyValidationService, languageService) + { + } + + public async Task ValidatePropertiesAsync( + ContentEditingModelBase contentEditingModelBase, + IContentType contentType, + IEnumerable? culturesToValidate = null) + => await HandlePropertiesValidationAsync(contentEditingModelBase, contentType, culturesToValidate); +} diff --git a/src/Umbraco.Core/Services/ElementVersionService.cs b/src/Umbraco.Core/Services/ElementVersionService.cs new file mode 100644 index 000000000000..c14f8b450b27 --- /dev/null +++ b/src/Umbraco.Core/Services/ElementVersionService.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class ElementVersionService : ContentVersionServiceBase, IElementVersionService +{ + public ElementVersionService( + ILogger logger, + IElementVersionRepository contentVersionRepository, + IContentVersionCleanupPolicy contentVersionCleanupPolicy, + ICoreScopeProvider scopeProvider, + IEventMessagesFactory eventMessagesFactory, + IAuditService auditService, + ILanguageRepository languageRepository, + IEntityService entityService, + IElementService contentService, + IUserIdKeyResolver userIdKeyResolver) + : base( + logger, + contentVersionRepository, + contentVersionCleanupPolicy, + scopeProvider, + eventMessagesFactory, + auditService, + languageRepository, + entityService, + contentService, + userIdKeyResolver) + { + } + + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Element; + + protected override DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion) + => new ElementDeletingVersionsNotification(id, messages, specificVersion); + + protected override DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion) + => new ElementDeletedVersionsNotification(id, messages, specificVersion); +} diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index c13111088edc..2d749110e4f2 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -40,6 +40,7 @@ public EntityService( { typeof(IMemberType).FullName!, UmbracoObjectTypes.MemberType }, { typeof(IMemberGroup).FullName!, UmbracoObjectTypes.MemberGroup }, { typeof(ITemplate).FullName!, UmbracoObjectTypes.Template }, + { typeof(IElement).FullName!, UmbracoObjectTypes.Element }, }; } @@ -191,6 +192,17 @@ public virtual IEnumerable GetAll(UmbracoObjectTypes objectType, pa } } + /// + public virtual IEnumerable GetAll(IEnumerable objectTypes, params int[] ids) + { + IEnumerable objectTypeGuids = objectTypes.Select(x => x.GetGuid()); + + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return _entityRepository.GetAll(objectTypeGuids, ids); + } + } + /// public virtual IEnumerable GetAll(Guid objectType) => GetAll(objectType, Array.Empty()); @@ -233,6 +245,17 @@ public IEnumerable GetAll(UmbracoObjectTypes objectType, Guid[] key } } + /// + public virtual IEnumerable GetAll(IEnumerable objectTypes, params Guid[] keys) + { + IEnumerable objectTypeGuids = objectTypes.Select(x => x.GetGuid()); + + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return _entityRepository.GetAll(objectTypeGuids, keys); + } + } + /// public virtual IEnumerable GetAll(Guid objectType, params Guid[] keys) { @@ -583,6 +606,48 @@ public IEnumerable GetPagedDescendants( } } + /// + public IEnumerable GetPagedDescendants( + Guid? parentKey, + UmbracoObjectTypes parentObjectType, + IEnumerable childObjectTypes, + int skip, + int take, + out long totalRecords, + IQuery? filter = null, + Ordering? ordering = null) + { + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + Guid objectTypeGuid = parentObjectType.GetGuid(); + IQuery query = Query(); + + if (parentKey.HasValue) + { + // lookup the path so we can use it in the prefix query below + TreeEntityPath[] paths = _entityRepository.GetAllPaths(objectTypeGuid, parentKey.Value).ToArray(); + if (paths.Length == 0) + { + totalRecords = 0; + return Enumerable.Empty(); + } + + var path = paths[0].Path; + query.Where(x => x.Path.SqlStartsWith(path + ",", TextColumnType.NVarchar)); + } + + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); + var objectTypeGuids = childObjectTypes.Select(x => x.GetGuid()).ToHashSet(); + if (pageSize == 0) + { + totalRecords = _entityRepository.CountByQuery(query, objectTypeGuids, filter); + return Enumerable.Empty(); + } + + return _entityRepository.GetPagedResultsByQuery(query, objectTypeGuids, pageNumber, pageSize, out totalRecords, filter, ordering); + } + } + /// public IEnumerable GetPagedDescendants( IEnumerable ids, @@ -710,9 +775,6 @@ public Attempt GetKey(int id, UmbracoObjectTypes umbracoObjectType) => /// public virtual IEnumerable GetAllPaths(UmbracoObjectTypes objectType, params int[]? ids) { - Type? entityType = objectType.GetClrType(); - GetObjectType(entityType); - using (ScopeProvider.CreateCoreScope(autoComplete: true)) { return _entityRepository.GetAllPaths(objectType.GetGuid(), ids); @@ -722,9 +784,6 @@ public virtual IEnumerable GetAllPaths(UmbracoObjectTypes object /// public virtual IEnumerable GetAllPaths(UmbracoObjectTypes objectType, params Guid[] keys) { - Type? entityType = objectType.GetClrType(); - GetObjectType(entityType); - using (ScopeProvider.CreateCoreScope(autoComplete: true)) { return _entityRepository.GetAllPaths(objectType.GetGuid(), keys); @@ -741,7 +800,7 @@ public int ReserveId(Guid key) } private int CountChildren(int id, UmbracoObjectTypes objectType, bool trashed = false, IQuery? filter = null) => - CountChildren(id, new HashSet() { objectType }, trashed, filter); + CountChildren(id, new HashSet { objectType }, trashed, filter); private int CountChildren( int id, @@ -830,7 +889,7 @@ public IEnumerable GetPagedChildren( if (take == 0) { - totalRecords = CountChildren(parentId, childObjectTypes, filter: filter); + totalRecords = CountChildren(parentId, childObjectTypes, trashed, filter); return Array.Empty(); } diff --git a/src/Umbraco.Core/Services/EntityTypeContainerService.cs b/src/Umbraco.Core/Services/EntityTypeContainerService.cs index 5c724e1529f0..e0792bb0db1c 100644 --- a/src/Umbraco.Core/Services/EntityTypeContainerService.cs +++ b/src/Umbraco.Core/Services/EntityTypeContainerService.cs @@ -237,12 +237,13 @@ public Task> GetAllAsync() return _entityContainerRepository.Get(treeEntity.ParentId); } - private async Task AuditAsync(AuditType type, Guid userKey, int objectId) => + protected async Task AuditAsync(AuditType type, Guid userKey, int objectId, string? comment = null) => await _auditService.AddAsync( type, userKey, objectId, - ContainerObjectType.GetName()); + ContainerObjectType.GetName(), + comment); private void ReadLock(ICoreScope scope) { diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index 239d6863c65b..a12068d94d5c 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -1,6 +1,4 @@ using System.Collections.Immutable; -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Persistence.Querying; @@ -10,24 +8,8 @@ namespace Umbraco.Cms.Core.Services; /// /// Defines the ContentService, which is an easy access to operations involving /// -public interface IContentService : IContentServiceBase +public interface IContentService : IPublishableContentService { - #region Rollback - - /// - /// Rolls back the content to a specific version. - /// - /// The id of the content node. - /// The version id to roll back to. - /// An optional culture to roll back. - /// The identifier of the user who is performing the roll back. - /// - /// When no culture is specified, all cultures are rolled back. - /// - OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId); - - #endregion - #region Blueprints /// @@ -142,13 +124,6 @@ IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId /// The . ContentScheduleCollection GetContentScheduleByContentId(int contentId); - /// - /// Persists publish/unpublish schedule for a content node. - /// - /// The content to persist the schedule for. - /// The content schedule collection. - void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule); - /// /// Gets documents. /// @@ -198,40 +173,6 @@ IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId /// The ancestor documents. IEnumerable GetAncestors(IContent content); - /// - /// Gets all versions of a document. - /// - /// The identifier of the document. - /// The document versions. - /// Versions are ordered with current first, then most recent first. - IEnumerable GetVersions(int id); - - /// - /// Gets all versions of a document. - /// - /// The identifier of the document. - /// The number of versions to skip. - /// The number of versions to take. - /// The document versions. - /// Versions are ordered with current first, then most recent first. - IEnumerable GetVersionsSlim(int id, int skip, int take); - - /// - /// Gets top versions of a document. - /// - /// The identifier of the document. - /// The number of top versions to get. - /// The version identifiers. - /// Versions are ordered with current first, then most recent first. - IEnumerable GetVersionIds(int id, int topRows); - - /// - /// Gets a version of a document. - /// - /// The version identifier. - /// The document version, or null if not found. - IContent? GetVersion(int versionId); - /// /// Gets root-level documents. /// @@ -422,17 +363,6 @@ IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out /// void DeleteOfType(int documentTypeId, int userId = Constants.Security.SuperUserId); - /// - /// Deletes all documents of given document types. - /// - /// The content type identifiers. - /// The identifier of the user performing the action. - /// - /// All non-deleted descendants of the deleted documents are moved to the recycle bin. - /// This operation is potentially dangerous and expensive. - /// - void DeleteOfTypes(IEnumerable contentTypeIds, int userId = Constants.Security.SuperUserId); - /// /// Deletes versions of a document prior to a given date. /// @@ -558,22 +488,6 @@ IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out /// IEnumerable PublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId); - /// - /// Unpublishes a document. - /// - /// - /// - /// By default, unpublishes the document as a whole, but it is possible to specify a culture to be - /// unpublished. Depending on whether that culture is mandatory, and other cultures remain published, - /// the document as a whole may or may not remain published. - /// - /// - /// If the content type is variant, then culture can be either '*' or an actual culture, but neither null nor - /// empty. If the content type is invariant, then culture can be either '*' or null or empty. - /// - /// - PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId); - /// /// Gets a value indicating whether a document is path-publishable. /// @@ -704,12 +618,4 @@ IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out /// The unique identifier of the user emptying the Recycle Bin. /// A task representing the asynchronous operation with the operation result. Task EmptyRecycleBinAsync(Guid userId); - - /// - /// Gets publish/unpublish schedule for a content node. - /// - /// The unique identifier of the content to load schedule for. - /// The . - ContentScheduleCollection GetContentScheduleByContentId(Guid contentId) => StaticServiceProvider.Instance - .GetRequiredService().GetContentScheduleByContentId(contentId); } diff --git a/src/Umbraco.Core/Services/IContentServiceBase.cs b/src/Umbraco.Core/Services/IContentServiceBase.cs index 1e07da7d8f91..f8d26dfdea6c 100644 --- a/src/Umbraco.Core/Services/IContentServiceBase.cs +++ b/src/Umbraco.Core/Services/IContentServiceBase.cs @@ -5,6 +5,11 @@ namespace Umbraco.Cms.Core.Services; public interface IContentServiceBase : IContentServiceBase where TItem : class, IContentBase { + /// + /// Gets a content item. + /// + /// The identifier of the content item. + /// The content item, or null if not found. TItem? GetById(Guid key); Attempt Save(IEnumerable contents, int userId = Constants.Security.SuperUserId); diff --git a/src/Umbraco.Core/Services/ICultureImpactFactory.cs b/src/Umbraco.Core/Services/ICultureImpactFactory.cs index 986b2f1aed2e..d204aeca07c3 100644 --- a/src/Umbraco.Core/Services/ICultureImpactFactory.cs +++ b/src/Umbraco.Core/Services/ICultureImpactFactory.cs @@ -14,7 +14,7 @@ public interface ICultureImpactFactory /// /// Validates that the culture is compatible with the variation. /// - CultureImpact? Create(string culture, bool isDefault, IContent content); + CultureImpact? Create(string culture, bool isDefault, IContentBase content); /// /// Gets the impact of 'all' cultures (including the invariant culture). diff --git a/src/Umbraco.Core/Services/IElementContainerService.cs b/src/Umbraco.Core/Services/IElementContainerService.cs new file mode 100644 index 000000000000..80f2b1e47b07 --- /dev/null +++ b/src/Umbraco.Core/Services/IElementContainerService.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface IElementContainerService : IEntityTypeContainerService +{ + Task> MoveAsync(Guid key, Guid? parentKey, Guid userKey); + + Task> MoveToRecycleBinAsync(Guid key, Guid userKey); + + Task> DeleteFromRecycleBinAsync(Guid key, Guid userKey); + + Task> EmptyRecycleBinAsync(Guid userKey); +} diff --git a/src/Umbraco.Core/Services/IElementEditingService.cs b/src/Umbraco.Core/Services/IElementEditingService.cs new file mode 100644 index 000000000000..db84e8cf8edf --- /dev/null +++ b/src/Umbraco.Core/Services/IElementEditingService.cs @@ -0,0 +1,29 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +// TODO ELEMENTS: fully define this interface +public interface IElementEditingService +{ + Task GetAsync(Guid key); + + Task> ValidateCreateAsync(ElementCreateModel createModel, Guid userKey); + + Task> ValidateUpdateAsync(Guid key, ValidateElementUpdateModel updateModel, Guid userKey); + + Task> CreateAsync(ElementCreateModel createModel, Guid userKey); + + Task> UpdateAsync(Guid key, ElementUpdateModel updateModel, Guid userKey); + + Task> DeleteAsync(Guid key, Guid userKey); + + Task> MoveAsync(Guid key, Guid? containerKey, Guid userKey); + + Task> CopyAsync(Guid key, Guid? parentKey, Guid userKey); + + Task> MoveToRecycleBinAsync(Guid key, Guid userKey); + + Task> DeleteFromRecycleBinAsync(Guid key, Guid userKey); +} diff --git a/src/Umbraco.Core/Services/IElementPermissionService.cs b/src/Umbraco.Core/Services/IElementPermissionService.cs new file mode 100644 index 000000000000..03abea08aaea --- /dev/null +++ b/src/Umbraco.Core/Services/IElementPermissionService.cs @@ -0,0 +1,91 @@ +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.AuthorizationStatus; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Manages permissions for element access. +/// +public interface IElementPermissionService +{ + /// + /// Authorize that a user has access to an element. + /// + /// to authorize. + /// The identifier of the element to check for access. + /// The permission to authorize. + /// A task resolving into a . + Task AuthorizeAccessAsync(IUser user, Guid elementKey, string permissionToCheck) + => AuthorizeAccessAsync(user, elementKey.Yield(), new HashSet { permissionToCheck }); + + /// + /// Authorize that a user has access to elements. + /// + /// to authorize. + /// The identifiers of the elements to check for access. + /// The collection of permissions to authorize. + /// A task resolving into a . + Task AuthorizeAccessAsync(IUser user, IEnumerable elementKeys, ISet permissionsToCheck); + + /// + /// Authorize that a user has access to the descendant items of an element. + /// + /// to authorize. + /// The identifier of the parent element to check its descendants for access. + /// The permission to authorize. + /// A task resolving into a . + Task AuthorizeDescendantsAccessAsync(IUser user, Guid parentKey, string permissionToCheck) + => AuthorizeDescendantsAccessAsync(user, parentKey, new HashSet { permissionToCheck }); + + /// + /// Authorize that a user has access to the descendant items of an element. + /// + /// to authorize. + /// The identifier of the parent element to check its descendants for access. + /// The collection of permissions to authorize. + /// A task resolving into a . + Task AuthorizeDescendantsAccessAsync(IUser user, Guid parentKey, ISet permissionsToCheck); + + /// + /// Authorize that a user is allowed to perform action on the element root. + /// + /// to authorize. + /// The permission to authorize. + /// A task resolving into a . + Task AuthorizeRootAccessAsync(IUser user, string permissionToCheck) + => AuthorizeRootAccessAsync(user, new HashSet { permissionToCheck }); + + /// + /// Authorize that a user is allowed to perform actions on the element root. + /// + /// to authorize. + /// The collection of permissions to authorize. + /// A task resolving into a . + Task AuthorizeRootAccessAsync(IUser user, ISet permissionsToCheck); + + /// + /// Authorize that a user is allowed to perform action on the element recycle bin. + /// + /// to authorize. + /// The permission to authorize. + /// A task resolving into a . + Task AuthorizeBinAccessAsync(IUser user, string permissionToCheck) + => AuthorizeBinAccessAsync(user, new HashSet { permissionToCheck }); + + /// + /// Authorize that a user is allowed to perform actions on the element recycle bin. + /// + /// to authorize. + /// The collection of permissions to authorize. + /// A task resolving into a . + Task AuthorizeBinAccessAsync(IUser user, ISet permissionsToCheck); + + /// + /// Authorize that a user has access to specific cultures. + /// + /// to authorize. + /// The collection of cultures to authorize. + /// A task resolving into a . + Task AuthorizeCultureAccessAsync(IUser user, ISet culturesToCheck); +} \ No newline at end of file diff --git a/src/Umbraco.Core/Services/IElementPublishingService.cs b/src/Umbraco.Core/Services/IElementPublishingService.cs new file mode 100644 index 000000000000..c42c3ca9fcb0 --- /dev/null +++ b/src/Umbraco.Core/Services/IElementPublishingService.cs @@ -0,0 +1,28 @@ +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface IElementPublishingService +{ + /// + /// Publishes an element. + /// + /// The key of the element. + /// The cultures to publish or schedule. + /// The identifier of the user performing the operation. + /// + Task> PublishAsync( + Guid key, + ICollection culturesToPublishOrSchedule, + Guid userKey); + + /// + /// Unpublishes multiple cultures of an element. + /// + /// The key of the element. + /// The cultures to unpublish. Use null to unpublish all cultures. + /// The identifier of the user performing the operation. + /// Status of the publish operation. + Task> UnpublishAsync(Guid key, ISet? cultures, Guid userKey); +} diff --git a/src/Umbraco.Core/Services/IElementService.cs b/src/Umbraco.Core/Services/IElementService.cs new file mode 100644 index 000000000000..ef189c35d97c --- /dev/null +++ b/src/Umbraco.Core/Services/IElementService.cs @@ -0,0 +1,23 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +// TODO ELEMENTS: fully define this interface +public interface IElementService : IPublishableContentService +{ + /// + /// Creates an element. + /// + /// The name of the element. + /// The content type alias. + /// The identifier of the user performing the action. + /// The created element. + IElement Create(string name, string contentTypeAlias, int userId = Constants.Security.SuperUserId); + + /// + /// Gets elements. + /// + /// The identifiers of the elements. + /// The elements. + IEnumerable GetByIds(IEnumerable keys); +} diff --git a/src/Umbraco.Core/Services/IElementValidationService.cs b/src/Umbraco.Core/Services/IElementValidationService.cs new file mode 100644 index 000000000000..6a31991cd0e8 --- /dev/null +++ b/src/Umbraco.Core/Services/IElementValidationService.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +internal interface IElementValidationService : IContentValidationServiceBase +{ +} diff --git a/src/Umbraco.Core/Services/IElementVersionService.cs b/src/Umbraco.Core/Services/IElementVersionService.cs new file mode 100644 index 000000000000..9a9524039862 --- /dev/null +++ b/src/Umbraco.Core/Services/IElementVersionService.cs @@ -0,0 +1,19 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface IElementVersionService +{ + /// + /// Removes historic content versions according to a policy. + /// + IReadOnlyCollection PerformContentVersionCleanup(DateTime asAtDate); + + ContentVersionMeta? Get(int versionId); + Task?, ContentVersionOperationStatus>> GetPagedContentVersionsAsync(Guid contentId, string? culture, int skip, int take); + Task> GetAsync(Guid versionId); + + Task> SetPreventCleanupAsync(Guid versionId, bool preventCleanup, Guid userKey); + Task> RollBackAsync(Guid versionId, string? culture, Guid userKey); +} diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index cab5615103f1..2686b6de7aba 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -112,6 +112,15 @@ IEnumerable GetAll(params int[] ids) /// If is empty, returns all entities. IEnumerable GetAll(UmbracoObjectTypes objectType, params int[] ids); + /// + /// Gets entities of multiple object types. + /// + /// The object types of the entities. + /// The identifiers of the entities. + /// If is empty, returns all entities of the specified types. + IEnumerable GetAll(IEnumerable objectTypes, params int[] ids) + => throw new NotImplementedException(); // TODO (V19): Remove default implementation. + /// /// Gets entities of a given object type. /// @@ -151,6 +160,15 @@ IEnumerable GetAll(params Guid[] keys) /// If is empty, returns all entities. IEnumerable GetAll(Guid objectType, params Guid[] keys); + /// + /// Gets entities of multiple object types. + /// + /// The object types of the entities. + /// The unique identifiers of the entities. + /// If is empty, returns all entities of the specified types. + IEnumerable GetAll(IEnumerable objectTypes, params Guid[] keys) + => throw new NotImplementedException(); // TODO (V19): Remove default implementation. + /// /// Gets entities at root. /// @@ -384,6 +402,19 @@ IEnumerable GetPagedDescendants( Ordering? ordering = null, bool includeTrashed = true); + /// + /// Gets descendants of an entity. + /// + IEnumerable GetPagedDescendants( + Guid? parentKey, + UmbracoObjectTypes parentObjectType, + IEnumerable childObjectTypes, + int skip, + int take, + out long totalRecords, + IQuery? filter = null, + Ordering? ordering = null); + /// /// Gets the object type of an entity. /// diff --git a/src/Umbraco.Core/Services/IPropertyValidationService.cs b/src/Umbraco.Core/Services/IPropertyValidationService.cs index 5937aab40eac..96f55bfaa31c 100644 --- a/src/Umbraco.Core/Services/IPropertyValidationService.cs +++ b/src/Umbraco.Core/Services/IPropertyValidationService.cs @@ -10,7 +10,7 @@ public interface IPropertyValidationService /// /// Validates the content item's properties pass validation rules /// - bool IsPropertyDataValid(IContent content, out IProperty[] invalidProperties, CultureImpact? impact); + bool IsPropertyDataValid(IPublishableContentBase content, out IProperty[] invalidProperties, CultureImpact? impact); /// /// Gets a value indicating whether the property has valid values. diff --git a/src/Umbraco.Core/Services/IPublishableContentService.cs b/src/Umbraco.Core/Services/IPublishableContentService.cs new file mode 100644 index 000000000000..de3cc9b28d89 --- /dev/null +++ b/src/Umbraco.Core/Services/IPublishableContentService.cs @@ -0,0 +1,129 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +// TODO ELEMENTS: fully define this interface +public interface IPublishableContentService : IContentServiceBase + where TContent : class, IPublishableContentBase +{ + /// + /// Saves content. + /// + /// The content to save. + /// The identifier of the user performing the action. + /// The content schedule collection. + /// The operation result. + OperationResult Save(TContent content, int? userId = null, ContentScheduleCollection? contentSchedule = null); + + /// + /// Deletes content. + /// + /// The content to delete. + /// The identifier of the user performing the action. + /// The operation result. + /// + /// This method will also delete associated media files, child content and possibly associated domains. + /// This method entirely clears the content from the database. + /// + OperationResult Delete(TContent content, int userId = Constants.Security.SuperUserId); + + /// + /// Deletes all content of given types. + /// + /// The content type identifiers. + /// The identifier of the user performing the action. + /// + /// All non-deleted descendants of the deleted content is moved to the recycle bin. + /// This operation is potentially dangerous and expensive. + /// + void DeleteOfTypes(IEnumerable contentTypeIds, int userId = Constants.Security.SuperUserId); + + /// + /// Gets publish/unpublish schedule for a content node. + /// + /// The unique identifier of the content to load schedule for. + /// The . + ContentScheduleCollection GetContentScheduleByContentId(Guid contentId); + + /// + /// Persists publish/unpublish schedule for a content node. + /// + /// The content to persist the schedule for. + /// The content schedule collection. + void PersistContentSchedule(IPublishableContentBase content, ContentScheduleCollection contentSchedule); + + /// + /// Publishes content + /// + /// + /// When a culture is being published, it includes all varying values along with all invariant values. + /// Wildcards (*) can be used as culture identifier to publish all cultures. + /// An empty array (or a wildcard) can be passed for culture invariant content. + /// + /// The content to publish. + /// The cultures to publish. + /// The identifier of the user performing the action. + PublishResult Publish(TContent content, string[] cultures, int userId = Constants.Security.SuperUserId); + + /// + /// Unpublishes content. + /// + /// + /// + /// By default, unpublishes the content as a whole, but it is possible to specify a culture to be + /// unpublished. Depending on whether that culture is mandatory, and other cultures remain published, + /// the content as a whole may or may not remain published. + /// + /// + /// If the content type is variant, then culture can be either '*' or an actual culture, but neither null nor + /// empty. If the content type is invariant, then culture can be either '*' or null or empty. + /// + /// + PublishResult Unpublish(TContent content, string? culture = "*", int userId = Constants.Security.SuperUserId); + + /// + /// Gets all versions of content. + /// + /// The identifier of the content. + /// The content versions. + /// Versions are ordered with current first, then most recent first. + IEnumerable GetVersions(int id); + + /// + /// Gets all versions of content. + /// + /// The identifier of the content. + /// The number of versions to skip. + /// The number of versions to take. + /// The content versions. + /// Versions are ordered with current first, then most recent first. + IEnumerable GetVersionsSlim(int id, int skip, int take); + + /// + /// Gets top versions of content. + /// + /// The identifier of the content. + /// The number of top versions to get. + /// The version identifiers. + /// Versions are ordered with current first, then most recent first. + IEnumerable GetVersionIds(int id, int topRows); + + /// + /// Gets a version of content. + /// + /// The version identifier. + /// The content version, or null if not found. + TContent? GetVersion(int versionId); + + /// + /// Rolls back the content to a specific version. + /// + /// The id of the content node. + /// The version id to roll back to. + /// An optional culture to roll back. + /// The identifier of the user who is performing the roll back. + /// + /// When no culture is specified, all cultures are rolled back. + /// + OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId); +} diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index 116e9301ab3e..9a30901c1a23 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -228,13 +228,23 @@ IEnumerable GetAll( Task, UserOperationStatus>> GetMediaPermissionsAsync(Guid userKey, IEnumerable mediaKeys); /// - /// Get explicitly assigned media permissions for a user and node keys. + /// Get explicitly assigned document permissions for a user and node keys. /// /// Key of user to retrieve permissions for. /// The keys of the content to get permissions for. /// An attempt indicating if the operation was a success as well as a more detailed , and an enumerable of permissions. Task, UserOperationStatus>> GetDocumentPermissionsAsync(Guid userKey, IEnumerable contentKeys); + /// + /// Get explicitly assigned element permissions for a user and node keys. + /// + /// Key of user to retrieve permissions for. + /// The keys of the elements to get permissions for. + /// An attempt indicating if the operation was a success as well as a more detailed , and an enumerable of permissions. + Task, UserOperationStatus>> GetElementPermissionsAsync( + Guid userKey, + IEnumerable elementKeys) => throw new NotImplementedException(); // TODO (V19): Remove default implementation. + /// /// Get explicitly assigned permissions for a user and optional node ids /// diff --git a/src/Umbraco.Core/Services/IdKeyMap.cs b/src/Umbraco.Core/Services/IdKeyMap.cs index 65aa4c5d2574..9444ceb6a000 100644 --- a/src/Umbraco.Core/Services/IdKeyMap.cs +++ b/src/Umbraco.Core/Services/IdKeyMap.cs @@ -125,6 +125,11 @@ public Attempt GetIdForKey(Guid key, UmbracoObjectTypes umbracoObjectType) return Attempt.Succeed(Constants.System.RecycleBinMedia); } + if (key == Constants.System.RecycleBinElementKey && umbracoObjectType is UmbracoObjectTypes.Element or UmbracoObjectTypes.ElementContainer) + { + return Attempt.Succeed(Constants.System.RecycleBinElement); + } + bool empty; try @@ -252,6 +257,11 @@ public Attempt GetKeyForId(int id, UmbracoObjectTypes umbracoObjectType) return Attempt.Succeed(Constants.System.RecycleBinMediaKey); } + if (id == Constants.System.RecycleBinElement && umbracoObjectType is UmbracoObjectTypes.Element or UmbracoObjectTypes.ElementContainer) + { + return Attempt.Succeed(Constants.System.RecycleBinElementKey); + } + bool empty; try diff --git a/src/Umbraco.Core/Services/OperationStatus/EntityContainerOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/EntityContainerOperationStatus.cs index 1d583564480d..c38162721505 100644 --- a/src/Umbraco.Core/Services/OperationStatus/EntityContainerOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/EntityContainerOperationStatus.cs @@ -10,5 +10,9 @@ public enum EntityContainerOperationStatus NotFound, ParentNotFound, NotEmpty, - DuplicateName + DuplicateName, + InvalidParent, + InTrash, + NotInTrash, + Unknown, } diff --git a/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs index d658e07565e0..3180058ec6ba 100644 --- a/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs @@ -22,4 +22,5 @@ public enum UserGroupOperationStatus Unauthorized, AdminGroupCannotBeEmpty, UserNotInGroup, + ElementStartNodeKeyNotFound, } diff --git a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs index 2702b9434433..e4940f146082 100644 --- a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs @@ -42,4 +42,5 @@ public enum UserOperationStatus DuplicateId, InvalidUserType, InvalidUserName, + ElementNodeNotFound, } diff --git a/src/Umbraco.Core/Services/PropertyValidationService.cs b/src/Umbraco.Core/Services/PropertyValidationService.cs index fe95f18911ed..cd03ae48abdc 100644 --- a/src/Umbraco.Core/Services/PropertyValidationService.cs +++ b/src/Umbraco.Core/Services/PropertyValidationService.cs @@ -116,7 +116,7 @@ public IEnumerable ValidatePropertyValue( } /// - public bool IsPropertyDataValid(IContent content, out IProperty[] invalidProperties, CultureImpact? impact) + public bool IsPropertyDataValid(IPublishableContentBase content, out IProperty[] invalidProperties, CultureImpact? impact) { // select invalid properties invalidProperties = content.Properties.Where(x => diff --git a/src/Umbraco.Core/Services/PublishResult.cs b/src/Umbraco.Core/Services/PublishResult.cs index 4e009cb49c90..341e05c79977 100644 --- a/src/Umbraco.Core/Services/PublishResult.cs +++ b/src/Umbraco.Core/Services/PublishResult.cs @@ -6,12 +6,12 @@ namespace Umbraco.Cms.Core.Services; /// /// Represents the result of publishing a document. /// -public class PublishResult : OperationResult +public class PublishResult : OperationResult { /// /// Initializes a new instance of the class. /// - public PublishResult(PublishResultType resultType, EventMessages? eventMessages, IContent content) + public PublishResult(PublishResultType resultType, EventMessages? eventMessages, IPublishableContentBase content) : base(resultType, eventMessages, content) { } @@ -19,7 +19,7 @@ public PublishResult(PublishResultType resultType, EventMessages? eventMessages, /// /// Initializes a new instance of the class. /// - public PublishResult(EventMessages eventMessages, IContent content) + public PublishResult(EventMessages eventMessages, IPublishableContentBase content) : base(PublishResultType.SuccessPublish, eventMessages, content) { } @@ -27,7 +27,7 @@ public PublishResult(EventMessages eventMessages, IContent content) /// /// Gets the document. /// - public IContent Content => Entity ?? throw new InvalidOperationException("The content entity was null. Nullability must have been circumvented when constructing this instance. Please don't do that."); + public IPublishableContentBase Content => Entity ?? throw new InvalidOperationException("The content entity was null. Nullability must have been circumvented when constructing this instance. Please don't do that."); /// /// Gets or sets the invalid properties, if the status failed due to validation. diff --git a/src/Umbraco.Core/Services/PublishableContentServiceBase.cs b/src/Umbraco.Core/Services/PublishableContentServiceBase.cs new file mode 100644 index 000000000000..b273a125887b --- /dev/null +++ b/src/Umbraco.Core/Services/PublishableContentServiceBase.cs @@ -0,0 +1,1937 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +// TODO ELEMENTS: ensure this implementation is up to date with the current state of ContentService +// TODO ELEMENTS: everything structural (children, ancestors, descendants, branches, sort) should be omitted from this base +// TODO ELEMENTS: implement recycle bin +// TODO ELEMENTS: implement copy and move +// TODO ELEMENTS: replace all "document" with "content" (variables, names and comments) +// TODO ELEMENTS: ensure all read and write locks use the abstract lock IDs (ReadLockIds, WriteLockIds) +// TODO ELEMENTS: rename _documentRepository to _contentRepository +public abstract class PublishableContentServiceBase : RepositoryService + where TContent : class, IPublishableContentBase +{ + private readonly IAuditService _auditService; + private readonly IContentTypeRepository _contentTypeRepository; + private readonly IPublishableContentRepository _documentRepository; + private readonly ILanguageRepository _languageRepository; + private readonly Lazy _propertyValidationService; + private readonly ICultureImpactFactory _cultureImpactFactory; + private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly PropertyEditorCollection _propertyEditorCollection; + private readonly IIdKeyMap _idKeyMap; + + protected PublishableContentServiceBase( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IAuditService auditService, + IContentTypeRepository contentTypeRepository, + IPublishableContentRepository contentRepository, + ILanguageRepository languageRepository, + Lazy propertyValidationService, + ICultureImpactFactory cultureImpactFactory, + IUserIdKeyResolver userIdKeyResolver, + PropertyEditorCollection propertyEditorCollection, + IIdKeyMap idKeyMap) + : base(provider, loggerFactory, eventMessagesFactory) + { + _auditService = auditService; + _contentTypeRepository = contentTypeRepository; + _documentRepository = contentRepository; + _languageRepository = languageRepository; + _propertyValidationService = propertyValidationService; + _cultureImpactFactory = cultureImpactFactory; + _userIdKeyResolver = userIdKeyResolver; + _propertyEditorCollection = propertyEditorCollection; + _idKeyMap = idKeyMap; + } + + protected abstract UmbracoObjectTypes ContentObjectType { get; } + + protected abstract int[] ReadLockIds { get; } + + protected abstract int[] WriteLockIds { get; } + + protected abstract bool SupportsBranchPublishing { get; } + + protected abstract ILogger> Logger { get; } + + protected abstract TContent CreateContentInstance(string name, int parentId, IContentType contentType, int userId); + + protected abstract TContent CreateContentInstance(string name, TContent parent, IContentType contentType, int userId); + + protected virtual PublishResult CommitDocumentChanges( + ICoreScope scope, + TContent content, + EventMessages eventMessages, + IReadOnlyCollection allLangs, + IDictionary? notificationState, + int userId) + => CommitDocumentChangesInternal(scope, content, eventMessages, allLangs, notificationState, userId); + + protected abstract void DeleteLocked(ICoreScope scope, TContent content, EventMessages evtMsgs); + + protected abstract SavingNotification SavingNotification(TContent content, EventMessages eventMessages); + + protected abstract SavedNotification SavedNotification(TContent content, EventMessages eventMessages); + + protected abstract SavingNotification SavingNotification(IEnumerable content, EventMessages eventMessages); + + protected abstract SavedNotification SavedNotification(IEnumerable content, EventMessages eventMessages); + + protected abstract TreeChangeNotification TreeChangeNotification(TContent content, TreeChangeTypes changeTypes, EventMessages eventMessages); + + protected abstract TreeChangeNotification TreeChangeNotification(TContent content, TreeChangeTypes changeTypes, IEnumerable? publishedCultures, IEnumerable? unpublishedCultures, EventMessages eventMessages); + + protected abstract TreeChangeNotification TreeChangeNotification(IEnumerable content, TreeChangeTypes changeTypes, EventMessages eventMessages); + + protected abstract DeletingNotification DeletingNotification(TContent content, EventMessages eventMessages); + + // TODO ELEMENTS: create a base class for publishing notifications to reuse between IContent and IElement + protected abstract CancelableEnumerableObjectNotification PublishingNotification(TContent content, EventMessages eventMessages); + + protected abstract IStatefulNotification PublishedNotification(TContent content, EventMessages eventMessages); + + protected abstract IStatefulNotification PublishedNotification(IEnumerable content, EventMessages eventMessages); + + protected abstract CancelableEnumerableObjectNotification UnpublishingNotification(TContent content, EventMessages eventMessages); + + protected abstract IStatefulNotification UnpublishedNotification(TContent content, EventMessages eventMessages); + + protected abstract RollingBackNotification RollingBackNotification(TContent target, EventMessages messages); + + protected abstract RolledBackNotification RolledBackNotification(TContent target, EventMessages messages); + + #region Rollback + + public OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId) + { + EventMessages evtMsgs = EventMessagesFactory.Get(); + + // Get the current copy of the node + TContent? content = GetById(id); + + // Get the version + TContent? version = GetVersion(versionId); + + // Good old null checks + if (content == null || version == null || content.Trashed) + { + return new OperationResult(OperationResultType.FailedCannot, evtMsgs); + } + + // Store the result of doing the save of content for the rollback + OperationResult rollbackSaveResult; + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + var rollingBackNotification = RollingBackNotification(content, evtMsgs); + if (scope.Notifications.PublishCancelable(rollingBackNotification)) + { + scope.Complete(); + return OperationResult.Cancel(evtMsgs); + } + + // Copy the changes from the version + content.CopyFrom(version, culture); + + // Save the content for the rollback + rollbackSaveResult = Save(content, userId); + + // Depending on the save result - is what we log & audit along with what we return + if (rollbackSaveResult.Success == false) + { + // Log the error/warning + Logger.LogError( + "User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, id, versionId); + } + else + { + scope.Notifications.Publish(RolledBackNotification(content, evtMsgs).WithStateFrom(rollingBackNotification)); + + // Logging & Audit message + Logger.LogInformation("User '{UserId}' rolled back content '{ContentId}' to version '{VersionId}'", userId, id, versionId); + Audit(AuditType.RollBack, userId, id, $"Content '{content.Name}' was rolled back to version '{versionId}'"); + } + + scope.Complete(); + } + + return rollbackSaveResult; + } + + #endregion + + #region Count + + public int CountPublished(string? contentTypeAlias = null) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.CountPublished(contentTypeAlias); + } + } + + public int Count(string? contentTypeAlias = null) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.Count(contentTypeAlias); + } + } + + public int CountChildren(int parentId, string? contentTypeAlias = null) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.CountChildren(parentId, contentTypeAlias); + } + } + + public int CountDescendants(int parentId, string? contentTypeAlias = null) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.CountDescendants(parentId, contentTypeAlias); + } + } + + #endregion + + #region Get, Has, Is + + /// + /// Gets an object by Id + /// + /// Id of the Content to retrieve + /// + /// + /// + public TContent? GetById(int id) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.Get(id); + } + } + + /// + /// Gets an object by Id + /// + /// Ids of the Content to retrieve + /// + /// + /// + public IEnumerable GetByIds(IEnumerable ids) + { + var idsA = ids.ToArray(); + if (idsA.Length == 0) + { + return Enumerable.Empty(); + } + + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + IEnumerable items = _documentRepository.GetMany(idsA); + var index = items.ToDictionary(x => x.Id, x => x); + return idsA.Select(x => index.GetValueOrDefault(x)).WhereNotNull(); + } + } + + /// + /// Gets an object by its 'UniqueId' + /// + /// Guid key of the Content to retrieve + /// + /// + /// + public TContent? GetById(Guid key) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.Get(key); + } + } + + /// + public ContentScheduleCollection GetContentScheduleByContentId(int contentId) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetContentSchedule(contentId); + } + } + + public ContentScheduleCollection GetContentScheduleByContentId(Guid contentId) + { + Attempt idAttempt = _idKeyMap.GetIdForKey(contentId, UmbracoObjectTypes.Document); + if (idAttempt.Success is false) + { + return new ContentScheduleCollection(); + } + + return GetContentScheduleByContentId(idAttempt.Result); + } + + /// + public void PersistContentSchedule(IPublishableContentBase content, ContentScheduleCollection contentSchedule) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(WriteLockIds); + _documentRepository.PersistContentSchedule(content, contentSchedule); + scope.Complete(); + } + } + + /// + /// Gets objects by Ids + /// + /// Ids of the Content to retrieve + /// + /// + /// + public IEnumerable GetByIds(IEnumerable ids) + { + Guid[] idsA = ids.ToArray(); + if (idsA.Length == 0) + { + return Enumerable.Empty(); + } + + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + IEnumerable? items = _documentRepository.GetMany(idsA); + + if (items is not null) + { + var index = items.ToDictionary(x => x.Key, x => x); + + return idsA.Select(x => index.GetValueOrDefault(x)).WhereNotNull(); + } + + return Enumerable.Empty(); + } + } + + /// + public IEnumerable GetPagedOfType( + int contentTypeId, + long pageIndex, + int pageSize, + out long totalRecords, + IQuery? filter = null, + Ordering? ordering = null) + { + if (pageIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } + + ordering ??= Ordering.By("sortOrder"); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetPage( + Query()?.Where(x => x.ContentTypeId == contentTypeId), + pageIndex, + pageSize, + out totalRecords, + null, + filter, + ordering); + } + } + + /// + public IEnumerable GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery? filter, Ordering? ordering = null) + { + if (pageIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } + + ordering ??= Ordering.By("sortOrder"); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + // Need to use a List here because the expression tree cannot convert the array when used in Contains. + // See ExpressionTests.Sql_In(). + List contentTypeIdsAsList = [.. contentTypeIds]; + + scope.ReadLock(ReadLockIds); + return _documentRepository.GetPage( + Query()?.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId)), + pageIndex, + pageSize, + out totalRecords, + null, + filter, + ordering); + } + } + + /// + /// Gets a specific version of an item. + /// + /// Id of the version to retrieve + /// An item + public TContent? GetVersion(int versionId) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetVersion(versionId); + } + } + + /// + /// Gets a collection of an objects versions by Id + /// + /// + /// An Enumerable list of objects + public IEnumerable GetVersions(int id) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetAllVersions(id); + } + } + + /// + /// Gets a collection of an objects versions by Id + /// + /// An Enumerable list of objects + public IEnumerable GetVersionsSlim(int id, int skip, int take) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetAllVersionsSlim(id, skip, take); + } + } + + /// + /// Gets a list of all version Ids for the given content item ordered so latest is first + /// + /// + /// The maximum number of rows to return + /// + public IEnumerable GetVersionIds(int id, int maxRows) + { + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return _documentRepository.GetVersionIds(id, maxRows); + } + } + + /// + public IEnumerable GetContentForExpiration(DateTime date) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetContentForExpiration(date); + } + } + + /// + public IEnumerable GetContentForRelease(DateTime date) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetContentForRelease(date); + } + } + + /// + /// Checks whether an item has any children + /// + /// Id of the + /// True if the content has any children otherwise False + public bool HasChildren(int id) => CountChildren(id) > 0; + + public bool IsPathPublished(TContent? content) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.IsPathPublished(content); + } + } + + /// + /// Gets the parent of the current content as an item. + /// + /// to retrieve the parent from + /// Parent object + public TContent? GetParent(TContent? content) + { + if (content?.ParentId == Constants.System.Root || content?.ParentId == Constants.System.RecycleBinContent || + content is null) + { + return null; + } + + return GetById(content.ParentId); + } + + #endregion + + #region Save, Publish, Unpublish + + /// + public OperationResult Save(TContent content, int? userId = null, ContentScheduleCollection? contentSchedule = null) + { + PublishedState publishedState = content.PublishedState; + if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) + { + throw new InvalidOperationException( + $"Cannot save (un)publishing content with name: {content.Name} - and state: {content.PublishedState}, use the dedicated SavePublished method."); + } + + if (content.Name != null && content.Name.Length > 255) + { + throw new InvalidOperationException( + $"Content with the name {content.Name} cannot be more than 255 characters in length."); + } + + EventMessages eventMessages = EventMessagesFactory.Get(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(WriteLockIds); + + SavingNotification savingNotification = SavingNotification(content, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return OperationResult.Cancel(eventMessages); + } + + userId ??= Constants.Security.SuperUserId; + + if (content.HasIdentity == false) + { + content.CreatorId = userId.Value; + } + + content.WriterId = userId.Value; + + // track the cultures that have changed + List? culturesChanging = content.ContentType.VariesByCulture() + ? content.CultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() + : null; + + // TODO: Currently there's no way to change track which variant properties have changed, we only have change + // tracking enabled on all values on the Property which doesn't allow us to know which variants have changed. + // in this particular case, determining which cultures have changed works with the above with names since it will + // have always changed if it's been saved in the back office but that's not really fail safe. + _documentRepository.Save(content); + + if (contentSchedule != null) + { + _documentRepository.PersistContentSchedule(content, contentSchedule); + } + + scope.Notifications.Publish(SavedNotification(content, eventMessages).WithStateFrom(savingNotification)); + + // TODO: we had code here to FORCE that this event can never be suppressed. But that just doesn't make a ton of sense?! + // I understand that if its suppressed that the caches aren't updated, but that would be expected. If someone + // is supressing events then I think it's expected that nothing will happen. They are probably doing it for perf + // reasons like bulk import and in those cases we don't want this occuring. + scope.Notifications.Publish(TreeChangeNotification(content, TreeChangeTypes.RefreshNode, eventMessages)); + + if (culturesChanging != null) + { + var langs = GetLanguageDetailsForAuditEntry(culturesChanging); + Audit(AuditType.SaveVariant, userId.Value, content.Id, $"Saved languages: {langs}", langs); + } + else + { + Audit(AuditType.Save, userId.Value, content.Id); + } + + scope.Complete(); + } + + return OperationResult.Succeed(eventMessages); + } + + /// + public OperationResult Save(IEnumerable contents, int userId = Constants.Security.SuperUserId) + { + EventMessages eventMessages = EventMessagesFactory.Get(); + TContent[] contentsA = contents.ToArray(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(WriteLockIds); + + SavingNotification savingNotification = SavingNotification(contentsA, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return OperationResult.Cancel(eventMessages); + } + + foreach (TContent content in contentsA) + { + if (content.HasIdentity == false) + { + content.CreatorId = userId; + } + + content.WriterId = userId; + + _documentRepository.Save(content); + } + + scope.Notifications.Publish(SavedNotification(contentsA, eventMessages).WithStateFrom(savingNotification)); + + // TODO: See note above about supressing events + scope.Notifications.Publish(TreeChangeNotification(contentsA, TreeChangeTypes.RefreshNode, eventMessages)); + + string contentIds = string.Join(", ", contentsA.Select(x => x.Id)); + Audit(AuditType.Save, userId, Constants.System.Root, $"Saved multiple content items (#{contentIds.Length})"); + + scope.Complete(); + } + + return OperationResult.Succeed(eventMessages); + } + + /// + public PublishResult Publish(TContent content, string[] cultures, int userId = Constants.Security.SuperUserId) + { + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (cultures is null) + { + throw new ArgumentNullException(nameof(cultures)); + } + + if (cultures.Any(c => c.IsNullOrWhiteSpace()) || cultures.Distinct().Count() != cultures.Length) + { + throw new ArgumentException("Cultures cannot be null or whitespace", nameof(cultures)); + } + + cultures = cultures.Select(x => x.EnsureCultureCode()!).ToArray(); + + EventMessages evtMsgs = EventMessagesFactory.Get(); + + // we need to guard against unsaved changes before proceeding; the content will be saved, but we're not firing any saved notifications + if (HasUnsavedChanges(content)) + { + return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, content); + } + + if (content.Name != null && content.Name.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } + + PublishedState publishedState = content.PublishedState; + if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) + { + throw new InvalidOperationException( + $"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); + } + + // cannot accept invariant (null or empty) culture for variant content type + // cannot accept a specific culture for invariant content type (but '*' is ok) + if (content.ContentType.VariesByCulture()) + { + if (cultures.Length > 1 && cultures.Contains("*")) + { + throw new ArgumentException("Cannot combine wildcard and specific cultures when publishing variant content types.", nameof(cultures)); + } + } + else + { + if (cultures.Length == 0) + { + cultures = new[] { "*" }; + } + + if (cultures[0] != "*" || cultures.Length > 1) + { + throw new ArgumentException($"Only wildcard culture is supported when publishing invariant content types.", nameof(cultures)); + } + } + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(WriteLockIds); + + var allLangs = _languageRepository.GetMany().ToList(); + + // this will create the correct culture impact even if culture is * or null + IEnumerable impacts = + cultures.Select(culture => _cultureImpactFactory.Create(culture, IsDefaultCulture(allLangs, culture), content)); + + // publish the culture(s) + // we don't care about the response here, this response will be rechecked below but we need to set the culture info values now. + var publishTime = DateTime.UtcNow; + foreach (CultureImpact? impact in impacts) + { + content.PublishCulture(impact, publishTime, _propertyEditorCollection); + } + + // Change state to publishing + content.PublishedState = PublishedState.Publishing; + + PublishResult result = CommitDocumentChanges(scope, content, evtMsgs, allLangs, new Dictionary(), userId); + scope.Complete(); + return result; + } + } + + /// + public PublishResult Unpublish(TContent content, string? culture = "*", int userId = Constants.Security.SuperUserId) + { + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } + + EventMessages evtMsgs = EventMessagesFactory.Get(); + + culture = culture?.NullOrWhiteSpaceAsNull().EnsureCultureCode(); + + PublishedState publishedState = content.PublishedState; + if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) + { + throw new InvalidOperationException( + $"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); + } + + // cannot accept invariant (null or empty) culture for variant content type + // cannot accept a specific culture for invariant content type (but '*' is ok) + if (content.ContentType.VariesByCulture()) + { + if (culture == null) + { + throw new NotSupportedException("Invariant culture is not supported by variant content types."); + } + } + else + { + if (culture != null && culture != "*") + { + throw new NotSupportedException( + $"Culture \"{culture}\" is not supported by invariant content types."); + } + } + + // if the content is not published, nothing to do + if (!content.Published) + { + return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); + } + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(WriteLockIds); + + var allLangs = _languageRepository.GetMany().ToList(); + + SavingNotification savingNotification = SavingNotification(content, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); + } + + // all cultures = unpublish whole + if (culture == "*" || (!content.ContentType.VariesByCulture() && culture == null)) + { + // Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will + // essentially be re-publishing the document with the requested culture removed + // We are however unpublishing all cultures, so we will set this to unpublishing. + content.UnpublishCulture(culture); + content.PublishedState = PublishedState.Unpublishing; + PublishResult result = CommitDocumentChanges(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + scope.Complete(); + return result; + } + else + { + // Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will + // essentially be re-publishing the document with the requested culture removed. + // The call to CommitDocumentChangesInternal will perform all the checks like if this is a mandatory culture or the last culture being unpublished + // and will then unpublish the document accordingly. + // If the result of this is false it means there was no culture to unpublish (i.e. it was already unpublished or it did not exist) + var removed = content.UnpublishCulture(culture); + + // Save and publish any changes + PublishResult result = CommitDocumentChanges(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + + scope.Complete(); + + // In one case the result will be PublishStatusType.FailedPublishNothingToPublish which means that no cultures + // were specified to be published which will be the case when removed is false. In that case + // we want to swap the result type to PublishResultType.SuccessUnpublishAlready (that was the expectation before). + if (result.Result == PublishResultType.FailedPublishNothingToPublish && !removed) + { + return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); + } + + return result; + } + } + } + + /// + public IEnumerable PerformScheduledPublish(DateTime date) + { + var allLangs = new Lazy>(() => _languageRepository.GetMany().ToList()); + EventMessages evtMsgs = EventMessagesFactory.Get(); + var results = new List(); + + PerformScheduledPublishingRelease(date, results, evtMsgs, allLangs); + PerformScheduledPublishingExpiration(date, results, evtMsgs, allLangs); + + return results; + } + + private void PerformScheduledPublishingExpiration(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + // do a fast read without any locks since this executes often to see if we even need to proceed + if (_documentRepository.HasContentForExpiration(date)) + { + // now take a write lock since we'll be updating + scope.WriteLock(WriteLockIds); + + foreach (TContent d in _documentRepository.GetContentForExpiration(date)) + { + ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(d.Id); + if (d.ContentType.VariesByCulture()) + { + // find which cultures have pending schedules + var pendingCultures = contentSchedule.GetPending(ContentScheduleAction.Expire, date) + .Select(x => x.Culture) + .Distinct() + .ToList(); + + if (pendingCultures.Count == 0) + { + continue; // shouldn't happen but no point in processing this document if there's nothing there + } + + SavingNotification savingNotification = SavingNotification(d, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + results.Add(new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d)); + continue; + } + + foreach (var c in pendingCultures) + { + // Clear this schedule for this culture + contentSchedule.Clear(c, ContentScheduleAction.Expire, date); + + // set the culture to be published + d.UnpublishCulture(c); + } + + _documentRepository.PersistContentSchedule(d, contentSchedule); + PublishResult result = CommitDocumentChanges(scope, d, evtMsgs, allLangs.Value, savingNotification.State, d.WriterId); + if (result.Success == false) + { + Logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); + } + + results.Add(result); + } + else + { + // Clear this schedule for this culture + contentSchedule.Clear(ContentScheduleAction.Expire, date); + _documentRepository.PersistContentSchedule(d, contentSchedule); + PublishResult result = Unpublish(d, userId: d.WriterId); + if (result.Success == false) + { + Logger.LogError(null, "Failed to unpublish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); + } + + results.Add(result); + } + } + + _documentRepository.ClearSchedule(date, ContentScheduleAction.Expire); + } + + scope.Complete(); + } + + private void PerformScheduledPublishingRelease(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + // do a fast read without any locks since this executes often to see if we even need to proceed + if (_documentRepository.HasContentForRelease(date)) + { + // now take a write lock since we'll be updating + scope.WriteLock(WriteLockIds); + + foreach (TContent d in _documentRepository.GetContentForRelease(date)) + { + ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(d.Id); + if (d.ContentType.VariesByCulture()) + { + // find which cultures have pending schedules + var pendingCultures = contentSchedule.GetPending(ContentScheduleAction.Release, date) + .Select(x => x.Culture) + .Distinct() + .ToList(); + + if (pendingCultures.Count == 0) + { + continue; // shouldn't happen but no point in processing this document if there's nothing there + } + SavingNotification savingNotification = SavingNotification(d, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + results.Add(new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d)); + continue; + } + + + var publishing = true; + foreach (var culture in pendingCultures) + { + // Clear this schedule for this culture + contentSchedule.Clear(culture, ContentScheduleAction.Release, date); + + if (d.Trashed) + { + continue; // won't publish + } + + // publish the culture values and validate the property values, if validation fails, log the invalid properties so the develeper has an idea of what has failed + IProperty[]? invalidProperties = null; + CultureImpact impact = _cultureImpactFactory.ImpactExplicit(culture, IsDefaultCulture(allLangs.Value, culture)); + var tryPublish = d.PublishCulture(impact, date, _propertyEditorCollection) && + _propertyValidationService.Value.IsPropertyDataValid(d, out invalidProperties, impact); + if (invalidProperties != null && invalidProperties.Length > 0) + { + Logger.LogWarning( + "Scheduled publishing will fail for document {DocumentId} and culture {Culture} because of invalid properties {InvalidProperties}", + d.Id, + culture, + string.Join(",", invalidProperties.Select(x => x.Alias))); + } + + publishing &= tryPublish; // set the culture to be published + if (!publishing) + { + } + } + + PublishResult result; + + if (d.Trashed) + { + result = new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d); + } + else if (!publishing) + { + result = new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, d); + } + else + { + _documentRepository.PersistContentSchedule(d, contentSchedule); + result = CommitDocumentChanges(scope, d, evtMsgs, allLangs.Value, savingNotification.State, d.WriterId); + } + + if (result.Success == false) + { + Logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); + } + + results.Add(result); + } + else + { + // Clear this schedule + contentSchedule.Clear(ContentScheduleAction.Release, date); + + PublishResult? result = null; + + if (d.Trashed) + { + result = new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d); + } + else + { + _documentRepository.PersistContentSchedule(d, contentSchedule); + result = Publish(d, d.AvailableCultures.ToArray(), userId: d.WriterId); + } + + if (result.Success == false) + { + Logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); + } + + results.Add(result); + } + } + + _documentRepository.ClearSchedule(date, ContentScheduleAction.Release); + } + + scope.Complete(); + } + + /// + /// Handles a lot of business logic cases for how the document should be persisted + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// Business logic cases such: as unpublishing a mandatory culture, or unpublishing the last culture, checking for + /// pending scheduled publishing, etc... is dealt with in this method. + /// There is quite a lot of cases to take into account along with logic that needs to deal with scheduled + /// saving/publishing, branch saving/publishing, etc... + /// + /// + protected PublishResult CommitDocumentChangesInternal( + ICoreScope scope, + TContent content, + EventMessages eventMessages, + IReadOnlyCollection allLangs, + IDictionary? notificationState, + int userId, + bool branchOne = false, + bool branchRoot = false) + { + if (scope == null) + { + throw new ArgumentNullException(nameof(scope)); + } + + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (eventMessages == null) + { + throw new ArgumentNullException(nameof(eventMessages)); + } + + PublishResult? publishResult = null; + PublishResult? unpublishResult = null; + + // nothing set = republish it all + if (content.PublishedState != PublishedState.Publishing && + content.PublishedState != PublishedState.Unpublishing) + { + content.PublishedState = PublishedState.Publishing; + } + + // State here is either Publishing or Unpublishing + // Publishing to unpublish a culture may end up unpublishing everything so these flags can be flipped later + var publishing = content.PublishedState == PublishedState.Publishing; + var unpublishing = content.PublishedState == PublishedState.Unpublishing; + + var variesByCulture = content.ContentType.VariesByCulture(); + + // Track cultures that are being published, changed, unpublished + IReadOnlyList? culturesPublishing = null; + IReadOnlyList? culturesUnpublishing = null; + IReadOnlyList? culturesChanging = variesByCulture + ? content.CultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() + : null; + + var isNew = !content.HasIdentity; + TreeChangeTypes changeType = isNew || SupportsBranchPublishing is false ? TreeChangeTypes.RefreshNode : TreeChangeTypes.RefreshBranch; + var previouslyPublished = content.HasIdentity && content.Published; + + // Inline method to persist the document with the documentRepository since this logic could be called a couple times below + void SaveDocument(TContent c) + { + // save, always + if (c.HasIdentity == false) + { + c.CreatorId = userId; + } + + c.WriterId = userId; + + // saving does NOT change the published version, unless PublishedState is Publishing or Unpublishing + _documentRepository.Save(c); + } + + if (publishing) + { + // Determine cultures publishing/unpublishing which will be based on previous calls to content.PublishCulture and ClearPublishInfo + culturesUnpublishing = content.GetCulturesUnpublishing(); + culturesPublishing = variesByCulture + ? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() + : null; + + // ensure that the document can be published, and publish handling events, business rules, etc + publishResult = StrategyCanPublish( + scope, + content, /*checkPath:*/ + !branchOne || branchRoot, + culturesPublishing, + culturesUnpublishing, + eventMessages, + allLangs, + notificationState); + + if (publishResult.Success) + { + // raise Publishing notification + if (scope.Notifications.PublishCancelable( + PublishingNotification(content, eventMessages).WithState(notificationState))) + { + Logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "publishing was cancelled"); + return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, eventMessages, content); + } + + // note: StrategyPublish flips the PublishedState to Publishing! + publishResult = StrategyPublish(content, culturesPublishing, culturesUnpublishing, eventMessages); + + // Check if a culture has been unpublished and if there are no cultures left, and then unpublish document as a whole + if (publishResult.Result == PublishResultType.SuccessUnpublishCulture && + content.PublishCultureInfos?.Count == 0) + { + // This is a special case! We are unpublishing the last culture and to persist that we need to re-publish without any cultures + // so the state needs to remain Publishing to do that. However, we then also need to unpublish the document and to do that + // the state needs to be Unpublishing and it cannot be both. This state is used within the documentRepository to know how to + // persist certain things. So before proceeding below, we need to save the Publishing state to publish no cultures, then we can + // mark the document for Unpublishing. + SaveDocument(content); + + // Set the flag to unpublish and continue + unpublishing = content.Published; // if not published yet, nothing to do + } + } + else + { + // in a branch, just give up + if (branchOne && !branchRoot) + { + return publishResult; + } + + // Check for mandatory culture missing, and then unpublish document as a whole + if (publishResult.Result == PublishResultType.FailedPublishMandatoryCultureMissing) + { + publishing = false; + unpublishing = content.Published; // if not published yet, nothing to do + + // we may end up in a state where we won't publish nor unpublish + // keep going, though, as we want to save anyways + } + + // reset published state from temp values (publishing, unpublishing) to original value + // (published, unpublished) in order to save the document, unchanged - yes, this is odd, + // but: (a) it means we don't reproduce the PublishState logic here and (b) setting the + // PublishState to anything other than Publishing or Unpublishing - which is precisely + // what we want to do here - throws + content.Published = content.Published; + } + } + + // won't happen in a branch + if (unpublishing) + { + TContent? newest = GetById(content.Id); // ensure we have the newest version - in scope + if (content.VersionId != newest?.VersionId) + { + return new PublishResult(PublishResultType.FailedPublishConcurrencyViolation, eventMessages, content); + } + + if (content.Published) + { + // ensure that the document can be unpublished, and unpublish + // handling events, business rules, etc + // note: StrategyUnpublish flips the PublishedState to Unpublishing! + // note: This unpublishes the entire document (not different variants) + unpublishResult = StrategyCanUnpublish(scope, content, eventMessages, notificationState); + if (unpublishResult.Success) + { + unpublishResult = StrategyUnpublish(content, eventMessages); + } + else + { + // reset published state from temp values (publishing, unpublishing) to original value + // (published, unpublished) in order to save the document, unchanged - yes, this is odd, + // but: (a) it means we don't reproduce the PublishState logic here and (b) setting the + // PublishState to anything other than Publishing or Unpublishing - which is precisely + // what we want to do here - throws + content.Published = content.Published; + return unpublishResult; + } + } + else + { + // already unpublished - optimistic concurrency collision, really, + // and I am not sure at all what we should do, better die fast, else + // we may end up corrupting the db + throw new InvalidOperationException("Concurrency collision."); + } + } + + // Persist the document + SaveDocument(content); + + // we have tried to unpublish - won't happen in a branch + if (unpublishing) + { + // and succeeded, trigger events + if (unpublishResult?.Success ?? false) + { + // events and audit + scope.Notifications.Publish(UnpublishedNotification(content, eventMessages).WithState(notificationState)); + scope.Notifications.Publish(TreeChangeNotification( + content, + SupportsBranchPublishing ? TreeChangeTypes.RefreshBranch : TreeChangeTypes.RefreshNode, + variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : null, + variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : ["*"], + eventMessages)); + + if (culturesUnpublishing != null) + { + // This will mean that that we unpublished a mandatory culture or we unpublished the last culture. + var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing); + Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); + + if (publishResult == null) + { + throw new PanicException("publishResult == null - should not happen"); + } + + switch (publishResult.Result) + { + case PublishResultType.FailedPublishMandatoryCultureMissing: + // Occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture) + + // Log that the whole content item has been unpublished due to mandatory culture unpublished + Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (mandatory language unpublished)"); + return new PublishResult(PublishResultType.SuccessUnpublishMandatoryCulture, eventMessages, content); + case PublishResultType.SuccessUnpublishCulture: + // Occurs when the last culture is unpublished + Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (last language unpublished)"); + return new PublishResult(PublishResultType.SuccessUnpublishLastCulture, eventMessages, content); + } + } + + Audit(AuditType.Unpublish, userId, content.Id); + return new PublishResult(PublishResultType.SuccessUnpublish, eventMessages, content); + } + + // or, failed + scope.Notifications.Publish(TreeChangeNotification(content, changeType, eventMessages)); + return new PublishResult(PublishResultType.FailedUnpublish, eventMessages, content); // bah + } + + // we have tried to publish + if (publishing) + { + // and succeeded, trigger events + if (publishResult?.Success ?? false) + { + if (isNew == false && previouslyPublished == false && SupportsBranchPublishing) + { + changeType = TreeChangeTypes.RefreshBranch; // whole branch + } + else if (isNew == false && previouslyPublished) + { + changeType = TreeChangeTypes.RefreshNode; // single node + } + + // invalidate the node/branch + // for branches, handled by SaveAndPublishBranch + if (!branchOne) + { + scope.Notifications.Publish( + TreeChangeNotification( + content, + changeType, + variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : ["*"], + variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : null, + eventMessages)); + scope.Notifications.Publish( + PublishedNotification(content, eventMessages).WithState(notificationState)); + } + + // it was not published and now is... descendants that were 'published' (but + // had an unpublished ancestor) are 're-published' ie not explicitly published + // but back as 'published' nevertheless + if (!branchOne && isNew == false && previouslyPublished == false && HasChildren(content.Id)) + { + TContent[] descendants = GetPublishedDescendantsLocked(content).ToArray(); + scope.Notifications.Publish( + PublishedNotification(descendants, eventMessages).WithState(notificationState)); + } + + switch (publishResult.Result) + { + case PublishResultType.SuccessPublish: + Audit(AuditType.Publish, userId, content.Id); + break; + case PublishResultType.SuccessPublishCulture: + if (culturesPublishing != null) + { + var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesPublishing); + Audit(AuditType.PublishVariant, userId, content.Id, $"Published languages: {langs}", langs); + } + + break; + case PublishResultType.SuccessUnpublishCulture: + if (culturesUnpublishing != null) + { + var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing); + Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); + } + + break; + } + + return publishResult; + } + } + + // should not happen + if (branchOne && !branchRoot) + { + throw new PanicException("branchOne && !branchRoot - should not happen"); + } + + // if publishing didn't happen or if it has failed, we still need to log which cultures were saved + if (!branchOne && (publishResult == null || !publishResult.Success)) + { + if (culturesChanging != null) + { + var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesChanging); + Audit(AuditType.SaveVariant, userId, content.Id, $"Saved languages: {langs}", langs); + } + else + { + Audit(AuditType.Save, userId, content.Id); + } + } + + // or, failed + scope.Notifications.Publish(TreeChangeNotification(content, changeType, eventMessages)); + return publishResult!; + } + + #endregion + + #region Delete + + /// + public OperationResult Delete(TContent content, int userId = Constants.Security.SuperUserId) + { + EventMessages eventMessages = EventMessagesFactory.Get(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(WriteLockIds); + + if (scope.Notifications.PublishCancelable(DeletingNotification(content, eventMessages))) + { + scope.Complete(); + return OperationResult.Cancel(eventMessages); + } + + // if it's not trashed yet, and published, we should unpublish + // but... Unpublishing event makes no sense (not going to cancel?) and no need to save + // just raise the event + if (content.Trashed == false && content.Published) + { + scope.Notifications.Publish(UnpublishedNotification(content, eventMessages)); + } + + DeleteLocked(scope, content, eventMessages); + + scope.Notifications.Publish(TreeChangeNotification(content, TreeChangeTypes.Remove, eventMessages)); + Audit(AuditType.Delete, userId, content.Id); + + scope.Complete(); + } + + return OperationResult.Succeed(eventMessages); + } + + // TODO: both DeleteVersions methods below have an issue. Sort of. They do NOT take care of files the way + // Delete does - for a good reason: the file may be referenced by other, non-deleted, versions. BUT, + // if that's not the case, then the file will never be deleted, because when we delete the content, + // the version referencing the file will not be there anymore. SO, we can leak files. + + /// + /// Permanently deletes versions from an object prior to a specific date. + /// This method will never delete the latest version of a content item. + /// + /// Id of the object to delete versions from + /// Latest version date + /// Optional Id of the User deleting versions of a Content object + public void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId) + { + EventMessages evtMsgs = EventMessagesFactory.Get(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(WriteLockIds); + + var deletingVersionsNotification = + new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: versionDate); + if (scope.Notifications.PublishCancelable(deletingVersionsNotification)) + { + scope.Complete(); + return; + } + + _documentRepository.DeleteVersions(id, versionDate); + + scope.Notifications.Publish( + new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: versionDate).WithStateFrom( + deletingVersionsNotification)); + Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version date)"); + + scope.Complete(); + } + } + + /// + /// Permanently deletes specific version(s) from an object. + /// This method will never delete the latest version of a content item. + /// + /// Id of the object to delete a version from + /// Id of the version to delete + /// Boolean indicating whether to delete versions prior to the versionId + /// Optional Id of the User deleting versions of a Content object + public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = Constants.Security.SuperUserId) + { + EventMessages evtMsgs = EventMessagesFactory.Get(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(WriteLockIds); + var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, versionId); + if (scope.Notifications.PublishCancelable(deletingVersionsNotification)) + { + scope.Complete(); + return; + } + + if (deletePriorVersions) + { + TContent? content = GetVersion(versionId); + DeleteVersions(id, content?.UpdateDate ?? DateTime.UtcNow, userId); + } + + TContent? c = _documentRepository.Get(id); + + // don't delete the current or published version + if (c?.VersionId != versionId && + c?.PublishedVersionId != versionId) + { + _documentRepository.DeleteVersion(versionId); + } + + scope.Notifications.Publish( + new ContentDeletedVersionsNotification(id, evtMsgs, versionId).WithStateFrom( + deletingVersionsNotification)); + Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version)"); + + scope.Complete(); + } + } + + #endregion + + #region Others + + protected static bool HasUnsavedChanges(TContent content) => content.HasIdentity is false || content.IsDirty(); + + protected static bool IsDefaultCulture(IReadOnlyCollection? langs, string culture) => + langs?.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)) ?? false; + + #endregion + + #region Internal Methods + + internal IEnumerable GetPublishedDescendantsLocked(TContent content) + { + var pathMatch = content.Path + ","; + IQuery query = Query() + .Where(x => x.Id != content.Id && x.Path.StartsWith(pathMatch) /*&& culture.Trashed == false*/); + IEnumerable contents = _documentRepository.Get(query); + + // beware! contents contains all published version below content + // including those that are not directly published because below an unpublished content + // these must be filtered out here + var parents = new List { content.Id }; + if (contents is not null) + { + foreach (TContent c in contents) + { + if (parents.Contains(c.ParentId)) + { + yield return c; + parents.Add(c.Id); + } + } + } + } + + #endregion + + #region Auditing + + protected void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) => + AuditAsync(type, userId, objectId, message, parameters).GetAwaiter().GetResult(); + + protected async Task AuditAsync(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) + { + Guid userKey = await _userIdKeyResolver.GetAsync(userId); + + await _auditService.AddAsync( + type, + userKey, + objectId, + ContentObjectType.GetName(), + message, + parameters); + } + + protected string GetLanguageDetailsForAuditEntry(IEnumerable affectedCultures) + => GetLanguageDetailsForAuditEntry(_languageRepository.GetMany(), affectedCultures); + + protected static string GetLanguageDetailsForAuditEntry(IEnumerable languages, IEnumerable affectedCultures) + { + IEnumerable languageIsoCodes = languages + .Where(x => affectedCultures.InvariantContains(x.IsoCode)) + .Select(x => x.IsoCode); + return string.Join(", ", languageIsoCodes); + } + + #endregion + + #region Content Types + + private IContentType GetContentType(ICoreScope scope, string contentTypeAlias) + { + if (contentTypeAlias == null) + { + throw new ArgumentNullException(nameof(contentTypeAlias)); + } + + if (string.IsNullOrWhiteSpace(contentTypeAlias)) + { + throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); + } + + scope.ReadLock(ReadLockIds); + + IQuery query = Query().Where(x => x.Alias == contentTypeAlias); + IContentType? contentType = _contentTypeRepository.Get(query).FirstOrDefault() + ?? + // causes rollback + throw new Exception($"No ContentType matching the passed in Alias: '{contentTypeAlias}'" + + $" was found"); + + return contentType; + } + + protected IContentType GetContentType(string contentTypeAlias) + { + if (contentTypeAlias == null) + { + throw new ArgumentNullException(nameof(contentTypeAlias)); + } + + if (string.IsNullOrWhiteSpace(contentTypeAlias)) + { + throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); + } + + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return GetContentType(scope, contentTypeAlias); + } + } + + #endregion + + #region Publishing Strategies + + /// + /// Ensures that a document can be published + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + private PublishResult StrategyCanPublish( + ICoreScope scope, + TContent content, + bool checkPath, + IReadOnlyList? culturesPublishing, + IReadOnlyCollection? culturesUnpublishing, + EventMessages evtMsgs, + IReadOnlyCollection allLangs, + IDictionary? notificationState) + { + var variesByCulture = content.ContentType.VariesByCulture(); + + // If it's null it's invariant + CultureImpact[] impactsToPublish = culturesPublishing == null + ? new[] { _cultureImpactFactory.ImpactInvariant() } + : culturesPublishing.Select(x => + _cultureImpactFactory.ImpactExplicit( + x, + allLangs.Any(lang => lang.IsoCode.InvariantEquals(x) && lang.IsMandatory))) + .ToArray(); + + // publish the culture(s) + var publishTime = DateTime.UtcNow; + if (!impactsToPublish.All(impact => content.PublishCulture(impact, publishTime, _propertyEditorCollection))) + { + return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content); + } + + // Validate the property values + IProperty[]? invalidProperties = null; + if (!impactsToPublish.All(x => + _propertyValidationService.Value.IsPropertyDataValid(content, out invalidProperties, x))) + { + return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content) + { + InvalidProperties = invalidProperties, + }; + } + + // Check if mandatory languages fails, if this fails it will mean anything that the published flag on the document will + // be changed to Unpublished and any culture currently published will not be visible. + if (variesByCulture) + { + if (culturesPublishing == null) + { + throw new InvalidOperationException( + "Internal error, variesByCulture but culturesPublishing is null."); + } + + if (content.Published && culturesPublishing.Count == 0 && culturesUnpublishing?.Count == 0) + { + // no published cultures = cannot be published + // This will occur if for example, a culture that is already unpublished is sent to be unpublished again, or vice versa, in that case + // there will be nothing to publish/unpublish. + return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); + } + + // missing mandatory culture = cannot be published + IEnumerable mandatoryCultures = allLangs.Where(x => x.IsMandatory).Select(x => x.IsoCode); + var mandatoryMissing = mandatoryCultures.Any(x => + !content.PublishedCultures.Contains(x, StringComparer.OrdinalIgnoreCase)); + if (mandatoryMissing) + { + return new PublishResult(PublishResultType.FailedPublishMandatoryCultureMissing, evtMsgs, content); + } + + if (culturesPublishing.Count == 0 && culturesUnpublishing?.Count > 0) + { + return new PublishResult(PublishResultType.SuccessUnpublishCulture, evtMsgs, content); + } + } + + // ensure that the document has published values + // either because it is 'publishing' or because it already has a published version + if (content.PublishedState != PublishedState.Publishing && content.PublishedVersionId == 0) + { + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", + content.Name, + content.Id, + "document does not have published values"); + return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); + } + + ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(content.Id); + + // loop over each culture publishing - or InvariantCulture for invariant + foreach (var culture in culturesPublishing ?? new[] { Constants.System.InvariantCulture }) + { + // ensure that the document status is correct + // note: culture will be string.Empty for invariant + switch (content.GetStatus(contentSchedule, culture)) + { + case ContentStatus.Expired: + if (!variesByCulture) + { + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document has expired"); + } + else + { + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", content.Name, content.Id, culture, "document culture has expired"); + } + + return new PublishResult( + !variesByCulture + ? PublishResultType.FailedPublishHasExpired : PublishResultType.FailedPublishCultureHasExpired, + evtMsgs, + content); + + case ContentStatus.AwaitingRelease: + if (!variesByCulture) + { + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", + content.Name, + content.Id, + "document is awaiting release"); + } + else + { + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", + content.Name, + content.Id, + culture, + "document has culture awaiting release"); + } + + return new PublishResult( + !variesByCulture + ? PublishResultType.FailedPublishAwaitingRelease + : PublishResultType.FailedPublishCultureAwaitingRelease, + evtMsgs, + content); + + case ContentStatus.Trashed: + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", + content.Name, + content.Id, + "document is trashed"); + return new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, content); + } + } + + if (checkPath && SupportsBranchPublishing) + { + // check if the content can be path-published + // root content can be published + // else check ancestors - we know we are not trashed + var pathIsOk = content.ParentId == Constants.System.Root || IsPathPublished(GetParent(content)); + if (!pathIsOk) + { + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", + content.Name, + content.Id, + "parent is not published"); + return new PublishResult(PublishResultType.FailedPublishPathNotPublished, evtMsgs, content); + } + } + + // If we are both publishing and unpublishing cultures, then return a mixed status + if (variesByCulture && culturesPublishing?.Count > 0 && culturesUnpublishing?.Count > 0) + { + return new PublishResult(PublishResultType.SuccessMixedCulture, evtMsgs, content); + } + + return new PublishResult(evtMsgs, content); + } + + /// + /// Publishes a document + /// + /// + /// + /// + /// + /// + /// + /// It is assumed that all publishing checks have passed before calling this method like + /// + /// + private PublishResult StrategyPublish( + TContent content, + IReadOnlyCollection? culturesPublishing, + IReadOnlyCollection? culturesUnpublishing, + EventMessages evtMsgs) + { + // change state to publishing + content.PublishedState = PublishedState.Publishing; + + // if this is a variant then we need to log which cultures have been published/unpublished and return an appropriate result + if (content.ContentType.VariesByCulture()) + { + if (content.Published && culturesUnpublishing?.Count == 0 && culturesPublishing?.Count == 0) + { + return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); + } + + if (culturesUnpublishing?.Count > 0) + { + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) cultures: {Cultures} have been unpublished.", + content.Name, + content.Id, + string.Join(",", culturesUnpublishing)); + } + + if (culturesPublishing?.Count > 0) + { + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) cultures: {Cultures} have been published.", + content.Name, + content.Id, + string.Join(",", culturesPublishing)); + } + + if (culturesUnpublishing?.Count > 0 && culturesPublishing?.Count > 0) + { + return new PublishResult(PublishResultType.SuccessMixedCulture, evtMsgs, content); + } + + if (culturesUnpublishing?.Count > 0 && culturesPublishing?.Count == 0) + { + return new PublishResult(PublishResultType.SuccessUnpublishCulture, evtMsgs, content); + } + + return new PublishResult(PublishResultType.SuccessPublishCulture, evtMsgs, content); + } + + Logger.LogInformation("Document {ContentName} (id={ContentId}) has been published.", content.Name, content.Id); + return new PublishResult(evtMsgs, content); + } + + /// + /// Ensures that a document can be unpublished + /// + /// + /// + /// + /// + /// + private PublishResult StrategyCanUnpublish( + ICoreScope scope, + TContent content, + EventMessages evtMsgs, + IDictionary? notificationState) + { + // raise Unpublishing notification + CancelableEnumerableObjectNotification notification = UnpublishingNotification(content, evtMsgs).WithState(notificationState); + var notificationResult = scope.Notifications.PublishCancelable(notification); + + if (notificationResult) + { + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) cannot be unpublished: unpublishing was cancelled.", content.Name, content.Id); + return new PublishResult(PublishResultType.FailedUnpublishCancelledByEvent, evtMsgs, content); + } + + return new PublishResult(PublishResultType.SuccessUnpublish, evtMsgs, content); + } + + /// + /// Unpublishes a document + /// + /// + /// + /// + /// + /// It is assumed that all unpublishing checks have passed before calling this method like + /// + /// + private PublishResult StrategyUnpublish(TContent content, EventMessages evtMsgs) + { + var attempt = new PublishResult(PublishResultType.SuccessUnpublish, evtMsgs, content); + + // TODO: What is this check?? we just created this attempt and of course it is Success?! + if (attempt.Success == false) + { + return attempt; + } + + // if the document has any release dates set to before now, + // they should be removed so they don't interrupt an unpublish + // otherwise it would remain released == published + ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(content.Id); + IReadOnlyList pastReleases = + contentSchedule.GetPending(ContentScheduleAction.Expire, DateTime.UtcNow); + foreach (ContentSchedule p in pastReleases) + { + contentSchedule.Remove(p); + } + + if (pastReleases.Count > 0) + { + Logger.LogInformation( + "Document {ContentName} (id={ContentId}) had its release date removed, because it was unpublished.", content.Name, content.Id); + } + + _documentRepository.PersistContentSchedule(content, contentSchedule); + + // change state to unpublishing + content.PublishedState = PublishedState.Unpublishing; + + Logger.LogInformation("Document {ContentName} (id={ContentId}) has been unpublished.", content.Name, content.Id); + return attempt; + } + + #endregion +} diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index ecc5989ef6a6..e0f954bd1a69 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -2072,31 +2072,48 @@ public void DeleteSectionFromAllUserGroups(string sectionAlias) } /// - public async Task, UserOperationStatus>> GetMediaPermissionsAsync(Guid userKey, IEnumerable mediaKeys) - { - using ICoreScope scope = ScopeProvider.CreateCoreScope(); - Attempt?> idAttempt = CreateIdKeyMap(mediaKeys, UmbracoObjectTypes.Media); - - if (idAttempt.Success is false || idAttempt.Result is null) - { - return Attempt.FailWithStatus(UserOperationStatus.MediaNodeNotFound, Enumerable.Empty()); - } - - Attempt, UserOperationStatus> permissions = await GetPermissionsAsync(userKey, idAttempt.Result); - scope.Complete(); + public async Task, UserOperationStatus>> GetMediaPermissionsAsync( + Guid userKey, + IEnumerable mediaKeys) + => await GetContentPermissionsAsync( + userKey, + mediaKeys, + UserOperationStatus.MediaNodeNotFound, + UmbracoObjectTypes.Media); - return permissions; - } + /// + public async Task, UserOperationStatus>> GetDocumentPermissionsAsync( + Guid userKey, + IEnumerable contentKeys) + => await GetContentPermissionsAsync( + userKey, + contentKeys, + UserOperationStatus.ContentNodeNotFound, + UmbracoObjectTypes.Document); /// - public async Task, UserOperationStatus>> GetDocumentPermissionsAsync(Guid userKey, IEnumerable contentKeys) + public async Task, UserOperationStatus>> GetElementPermissionsAsync( + Guid userKey, + IEnumerable elementKeys) + => await GetContentPermissionsAsync( + userKey, + elementKeys, + UserOperationStatus.ElementNodeNotFound, + UmbracoObjectTypes.Element, + UmbracoObjectTypes.ElementContainer); + + private async Task, UserOperationStatus>> GetContentPermissionsAsync( + Guid userKey, + IEnumerable contentKeys, + UserOperationStatus failedOperationStatus, + params UmbracoObjectTypes[] objectTypes) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); - Attempt?> idAttempt = CreateIdKeyMap(contentKeys, UmbracoObjectTypes.Document); + Attempt?> idAttempt = CreateIdKeyMap(contentKeys, objectTypes); if (idAttempt.Success is false || idAttempt.Result is null) { - return Attempt.FailWithStatus(UserOperationStatus.ContentNodeNotFound, Enumerable.Empty()); + return Attempt.FailWithStatus(failedOperationStatus, Enumerable.Empty()); } Attempt, UserOperationStatus> permissions = await GetPermissionsAsync(userKey, idAttempt.Result); @@ -2105,7 +2122,6 @@ public async Task, UserOperationStatus>> Ge return permissions; } - private async Task, UserOperationStatus>> GetPermissionsAsync(Guid userKey, Dictionary nodes) { IUser? user = await GetAsync(userKey); @@ -2130,20 +2146,32 @@ private async Task, UserOperationStatus>> G return Attempt.SucceedWithStatus, UserOperationStatus>(UserOperationStatus.Success, results); } - private Attempt?> CreateIdKeyMap(IEnumerable nodeKeys, UmbracoObjectTypes objectType) + private Attempt?> CreateIdKeyMap(IEnumerable nodeKeys, params UmbracoObjectTypes[] objectTypes) { // We'll return this as a dictionary we can link the id and key again later. Dictionary idKeys = new(); foreach (Guid key in nodeKeys) { - Attempt idAttempt = _entityService.GetId(key, objectType); - if (idAttempt.Success is false) + Attempt? successfulAttempt = null; + foreach (UmbracoObjectTypes objectType in objectTypes) + { + Attempt idAttempt = _entityService.GetId(key, objectType); + if (idAttempt.Success is false) + { + continue; + } + + successfulAttempt = idAttempt; + break; + } + + if (successfulAttempt is null) { return Attempt.Fail?>(null); } - idKeys[key] = idAttempt.Result; + idKeys[key] = successfulAttempt.Value.Result; } return Attempt.Succeed?>(idKeys); diff --git a/src/Umbraco.Core/UdiEntityTypeHelper.cs b/src/Umbraco.Core/UdiEntityTypeHelper.cs index 40d5b8dd5990..aa995e411717 100644 --- a/src/Umbraco.Core/UdiEntityTypeHelper.cs +++ b/src/Umbraco.Core/UdiEntityTypeHelper.cs @@ -46,6 +46,10 @@ public static string FromUmbracoObjectType(UmbracoObjectTypes umbracoObjectType) return Constants.UdiEntityType.FormsDataSource; case UmbracoObjectTypes.Language: return Constants.UdiEntityType.Language; + case UmbracoObjectTypes.Element: + return Constants.UdiEntityType.Element; + case UmbracoObjectTypes.ElementContainer: + return Constants.UdiEntityType.ElementContainer; } throw new NotSupportedException( diff --git a/src/Umbraco.Core/UdiParser.cs b/src/Umbraco.Core/UdiParser.cs index c7169046b01d..d31ff969028b 100644 --- a/src/Umbraco.Core/UdiParser.cs +++ b/src/Umbraco.Core/UdiParser.cs @@ -211,6 +211,7 @@ public static Dictionary GetKnownUdiTypes() => { Constants.UdiEntityType.DocumentType, UdiType.GuidUdi }, { Constants.UdiEntityType.DocumentTypeContainer, UdiType.GuidUdi }, { Constants.UdiEntityType.Element, UdiType.GuidUdi }, + { Constants.UdiEntityType.ElementContainer, UdiType.GuidUdi }, { Constants.UdiEntityType.Media, UdiType.GuidUdi }, { Constants.UdiEntityType.MediaType, UdiType.GuidUdi }, { Constants.UdiEntityType.MediaTypeContainer, UdiType.GuidUdi }, diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 7ec1e8dfa3d4..e8f2415d7b14 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -421,6 +421,7 @@ public static IUmbracoBuilder AddCoreNotifications(this IUmbracoBuilder builder) .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() ; // add notification handlers for auditing diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index c548a7949934..1782ce8851b4 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -3,6 +3,7 @@ using Umbraco.Cms.Core.DynamicRoot.QuerySteps; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Factories; using Umbraco.Cms.Infrastructure.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; using Umbraco.Cms.Infrastructure.Services.Implement; @@ -87,6 +88,9 @@ internal static IUmbracoBuilder AddRepositories(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index b296218cfbb2..0e56085b569c 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs @@ -211,10 +211,91 @@ private void CreateUserGroup2PermissionData() { var userGroupKeyToPermissions = new Dictionary>() { - [Constants.Security.AdminGroupKey] = [ActionNew.ActionLetter, ActionUpdate.ActionLetter, ActionDelete.ActionLetter, ActionMove.ActionLetter, ActionCopy.ActionLetter, ActionSort.ActionLetter, ActionRollback.ActionLetter, ActionProtect.ActionLetter, ActionAssignDomain.ActionLetter, ActionPublish.ActionLetter, ActionRights.ActionLetter, ActionUnpublish.ActionLetter, ActionBrowse.ActionLetter, ActionCreateBlueprintFromContent.ActionLetter, ActionNotify.ActionLetter, ":", "5", "7", "T", ActionDocumentPropertyRead.ActionLetter, ActionDocumentPropertyWrite.ActionLetter], - [Constants.Security.EditorGroupKey] = [ActionNew.ActionLetter, ActionUpdate.ActionLetter, ActionDelete.ActionLetter, ActionMove.ActionLetter, ActionCopy.ActionLetter, ActionSort.ActionLetter, ActionRollback.ActionLetter, ActionProtect.ActionLetter, ActionPublish.ActionLetter, ActionUnpublish.ActionLetter, ActionBrowse.ActionLetter, ActionCreateBlueprintFromContent.ActionLetter, ActionNotify.ActionLetter, ":", "5", "T", ActionDocumentPropertyRead.ActionLetter, ActionDocumentPropertyWrite.ActionLetter], - [Constants.Security.WriterGroupKey] = [ActionNew.ActionLetter, ActionUpdate.ActionLetter, ActionBrowse.ActionLetter, ActionNotify.ActionLetter, ":", ActionDocumentPropertyRead.ActionLetter, ActionDocumentPropertyWrite.ActionLetter], - [Constants.Security.TranslatorGroupKey] = [ActionUpdate.ActionLetter, ActionBrowse.ActionLetter, ActionDocumentPropertyRead.ActionLetter, ActionDocumentPropertyWrite.ActionLetter], + [Constants.Security.AdminGroupKey] = + [ + ActionNew.ActionLetter, + ActionUpdate.ActionLetter, + ActionDelete.ActionLetter, + ActionMove.ActionLetter, + ActionCopy.ActionLetter, + ActionSort.ActionLetter, + ActionRollback.ActionLetter, + ActionProtect.ActionLetter, + ActionAssignDomain.ActionLetter, + ActionPublish.ActionLetter, + ActionRights.ActionLetter, + ActionUnpublish.ActionLetter, + ActionBrowse.ActionLetter, + ActionCreateBlueprintFromContent.ActionLetter, + ActionNotify.ActionLetter, + ":", + "5", + "7", + "T", + ActionDocumentPropertyRead.ActionLetter, + ActionDocumentPropertyWrite.ActionLetter, + ActionElementNew.ActionLetter, + ActionElementUpdate.ActionLetter, + ActionElementDelete.ActionLetter, + ActionElementMove.ActionLetter, + ActionElementCopy.ActionLetter, + ActionElementPublish.ActionLetter, + ActionElementUnpublish.ActionLetter, + ActionElementBrowse.ActionLetter, + ActionElementRollback.ActionLetter, + ], + [Constants.Security.EditorGroupKey] = + [ + ActionNew.ActionLetter, + ActionUpdate.ActionLetter, + ActionDelete.ActionLetter, + ActionMove.ActionLetter, + ActionCopy.ActionLetter, + ActionSort.ActionLetter, + ActionRollback.ActionLetter, + ActionProtect.ActionLetter, + ActionPublish.ActionLetter, + ActionUnpublish.ActionLetter, + ActionBrowse.ActionLetter, + ActionCreateBlueprintFromContent.ActionLetter, + ActionNotify.ActionLetter, + ":", + "5", + "T", + ActionDocumentPropertyRead.ActionLetter, + ActionDocumentPropertyWrite.ActionLetter, + ActionElementNew.ActionLetter, + ActionElementUpdate.ActionLetter, + ActionElementDelete.ActionLetter, + ActionElementMove.ActionLetter, + ActionElementCopy.ActionLetter, + ActionElementPublish.ActionLetter, + ActionElementUnpublish.ActionLetter, + ActionElementBrowse.ActionLetter, + ActionElementRollback.ActionLetter, + ], + [Constants.Security.WriterGroupKey] = + [ + ActionNew.ActionLetter, + ActionUpdate.ActionLetter, + ActionBrowse.ActionLetter, + ActionNotify.ActionLetter, + ":", + ActionDocumentPropertyRead.ActionLetter, + ActionDocumentPropertyWrite.ActionLetter, + ActionElementNew.ActionLetter, + ActionElementUpdate.ActionLetter, + ActionElementBrowse.ActionLetter, + ], + [Constants.Security.TranslatorGroupKey] = + [ + ActionUpdate.ActionLetter, + ActionBrowse.ActionLetter, + ActionDocumentPropertyRead.ActionLetter, + ActionDocumentPropertyWrite.ActionLetter, + ActionElementUpdate.ActionLetter, + ActionElementBrowse.ActionLetter, + ], }; var i = 1; @@ -326,6 +407,21 @@ void InsertDataTypeNodeDto(int id, int sortOrder, string uniqueId, string text) NodeObjectType = Constants.ObjectTypes.MediaRecycleBin, CreateDate = DateTime.UtcNow, }); + _database.Insert(Constants.DatabaseSchema.Tables.Node, "id", false, + new NodeDto + { + NodeId = Constants.System.RecycleBinElement, + Trashed = false, + ParentId = -1, + UserId = -1, + Level = 0, + Path = "-1,-22", + SortOrder = 0, + UniqueId = Constants.System.RecycleBinElementKey, + Text = "Recycle Bin", + NodeObjectType = Constants.ObjectTypes.ElementRecycleBin, + CreateDate = DateTime.UtcNow, + }); InsertDataTypeNodeDto( Constants.DataTypes.LabelString, @@ -1114,6 +1210,7 @@ private void CreateLockData() _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.DistributedJobs, Name = "DistributedJobs" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.CacheVersion, Name = "CacheVersion" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.DocumentUrlAliases, Name = "DocumentUrlAliases" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.ElementTree, Name = "ElementTree" }); } private void CreateContentTypeData() @@ -1299,6 +1396,7 @@ private void CreateUserGroupData() Key = Constants.Security.AdminGroupKey, StartMediaId = -1, StartContentId = -1, + StartElementId = -1, Alias = Constants.Security.AdminGroupAlias, Name = "Administrators", Description = "Users with full access to all sections and functionality", @@ -1317,6 +1415,7 @@ private void CreateUserGroupData() Key = Constants.Security.WriterGroupKey, StartMediaId = -1, StartContentId = -1, + StartElementId = -1, Alias = WriterGroupAlias, Name = "Writers", Description = "Users with permission to create and update but not publish content", @@ -1335,6 +1434,7 @@ private void CreateUserGroupData() Key = Constants.Security.EditorGroupKey, StartMediaId = -1, StartContentId = -1, + StartElementId = -1, Alias = EditorGroupAlias, Name = "Editors", Description = "Users with full permission to create, update and publish content", @@ -1353,6 +1453,7 @@ private void CreateUserGroupData() Key = Constants.Security.TranslatorGroupKey, StartMediaId = -1, StartContentId = -1, + StartElementId = -1, Alias = TranslatorGroupAlias, Name = "Translators", Description = "Users with permission to manage dictionary entries", @@ -1403,12 +1504,15 @@ private void CreateUserGroup2AppData() _database.Insert(new UserGroup2AppDto { UserGroupId = 1, AppAlias = Constants.Applications.Users }); _database.Insert(new UserGroup2AppDto { UserGroupId = 1, AppAlias = Constants.Applications.Forms }); _database.Insert(new UserGroup2AppDto { UserGroupId = 1, AppAlias = Constants.Applications.Translation }); + _database.Insert(new UserGroup2AppDto { UserGroupId = 1, AppAlias = Constants.Applications.Library }); _database.Insert(new UserGroup2AppDto { UserGroupId = 2, AppAlias = Constants.Applications.Content }); + _database.Insert(new UserGroup2AppDto { UserGroupId = 2, AppAlias = Constants.Applications.Library }); _database.Insert(new UserGroup2AppDto { UserGroupId = 3, AppAlias = Constants.Applications.Content }); _database.Insert(new UserGroup2AppDto { UserGroupId = 3, AppAlias = Constants.Applications.Media }); _database.Insert(new UserGroup2AppDto { UserGroupId = 3, AppAlias = Constants.Applications.Forms }); + _database.Insert(new UserGroup2AppDto { UserGroupId = 3, AppAlias = Constants.Applications.Library }); _database.Insert(new UserGroup2AppDto { UserGroupId = 4, AppAlias = Constants.Applications.Translation }); } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index 1435feabbf24..a649223cd214 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -35,6 +35,7 @@ public class DatabaseSchemaCreator typeof(ContentVersionDto), typeof(MediaVersionDto), typeof(DocumentDto), + typeof(ElementDto), typeof(ContentTypeTemplateDto), typeof(DataTypeDto), typeof(DictionaryDto), @@ -73,6 +74,7 @@ public class DatabaseSchemaCreator typeof(UserStartNodeDto), typeof(ContentNuDto), typeof(DocumentVersionDto), + typeof(ElementVersionDto), typeof(DocumentUrlDto), typeof(DocumentUrlAliasDto), typeof(KeyValueDto), @@ -81,6 +83,7 @@ public class DatabaseSchemaCreator typeof(AuditEntryDto), typeof(ContentVersionCultureVariationDto), typeof(DocumentCultureVariationDto), + typeof(ElementCultureVariationDto), typeof(ContentScheduleDto), typeof(LogViewerQueryDto), typeof(ContentVersionCleanupPolicyDto), diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 0caf50a15883..721f3b650b81 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -154,5 +154,6 @@ protected virtual void DefinePlan() // To 18.0.0 // TODO (V18): Enable on 18 branch //// To("{74332C49-B279-4945-8943-F8F00B1F5949}"); + To("{E51033DE-B4F9-45F3-87B3-0E774B2939C2}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/AddElements.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/AddElements.cs new file mode 100644 index 000000000000..5e580c2f9cde --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/AddElements.cs @@ -0,0 +1,169 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0; + +public class AddElements : AsyncMigrationBase +{ + public AddElements(IMigrationContext context) + : base(context) + { + } + + protected override Task MigrateAsync() + { + EnsureElementTreeLock(); + EnsureElementTables(); + EnsureElementRecycleBin(); + EnsureElementStartNodeColumn(); + EnsureAdminGroupElementAccess(); + EnsureAdminGroupElementPermissions(); + return Task.CompletedTask; + } + + private void EnsureElementTreeLock() + { + Sql sql = Database.SqlContext.Sql() + .Select() + .From() + .Where(x => x.Id == Constants.Locks.ElementTree); + + LockDto? cacheVersionLock = Database.Fetch(sql).FirstOrDefault(); + + if (cacheVersionLock is null) + { + Database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.ElementTree, Name = "ElementTree" }); + } + } + + private void EnsureElementTables() + { + if (!TableExists(Constants.DatabaseSchema.Tables.Element)) + { + Create.Table().Do(); + } + + if (!TableExists(Constants.DatabaseSchema.Tables.ElementVersion)) + { + Create.Table().Do(); + } + + if (!TableExists(Constants.DatabaseSchema.Tables.ElementCultureVariation)) + { + Create.Table().Do(); + } + } + + private void EnsureElementRecycleBin() + { + Sql sql = Database.SqlContext.Sql() + .Select(x => x.NodeId) + .From() + .Where(x => x.UniqueId == Constants.System.RecycleBinElementKey); + + if (Database.FirstOrDefault(sql) is not null) + { + return; + } + + ToggleIdentityInsertForNodes(true); + try + { + Database.Insert( + Constants.DatabaseSchema.Tables.Node, + "id", + false, + new NodeDto + { + NodeId = Constants.System.RecycleBinElement, + Trashed = false, + ParentId = -1, + UserId = -1, + Level = 0, + Path = "-1,-22", + SortOrder = 0, + UniqueId = Constants.System.RecycleBinElementKey, + Text = "Recycle Bin", + NodeObjectType = Constants.ObjectTypes.ElementRecycleBin, + CreateDate = DateTime.UtcNow, + }); + } + finally + { + ToggleIdentityInsertForNodes(false); + } + } + + private void ToggleIdentityInsertForNodes(bool toggleOn) + { + if (SqlSyntax.SupportsIdentityInsert()) + { + Database.Execute(new Sql($"SET IDENTITY_INSERT {SqlSyntax.GetQuotedTableName(NodeDto.TableName)} {(toggleOn ? "ON" : "OFF")} ")); + } + } + + private void EnsureElementStartNodeColumn() + { + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToList(); + + if (columns.Any(x => x.TableName.InvariantEquals(Constants.DatabaseSchema.Tables.UserGroup) + && x.ColumnName.InvariantEquals("startElementId")) == false) + { + AddColumn(Constants.DatabaseSchema.Tables.UserGroup, "startElementId"); + } + } + + private void EnsureAdminGroupElementAccess() + { + // Set startElementId to -1 (root) for admin group if not already set + Sql sql = Database.SqlContext.Sql() + .Update(u => u.Set(x => x.StartElementId, Constants.System.Root)) + .Where(x => x.Key == Constants.Security.AdminGroupKey && x.StartElementId == null); + + Database.Execute(sql); + } + + private void EnsureAdminGroupElementPermissions() + { + // Check if admin group already has any element permissions + Sql existingPermissionsSql = Database.SqlContext.Sql() + .Select() + .From() + .Where(x => + x.UserGroupKey == Constants.Security.AdminGroupKey && + x.Permission == ActionElementBrowse.ActionLetter); + + if (Database.Fetch(existingPermissionsSql).Count != 0) + { + return; + } + + // Add all element permissions for admin group + var elementPermissions = new[] + { + ActionElementNew.ActionLetter, + ActionElementUpdate.ActionLetter, + ActionElementDelete.ActionLetter, + ActionElementMove.ActionLetter, + ActionElementCopy.ActionLetter, + ActionElementPublish.ActionLetter, + ActionElementUnpublish.ActionLetter, + ActionElementBrowse.ActionLetter, + ActionElementRollback.ActionLetter, + }; + + UserGroup2PermissionDto[] permissionDtos = elementPermissions + .Select(permission => new UserGroup2PermissionDto + { + UserGroupKey = Constants.Security.AdminGroupKey, + Permission = permission, + }) + .ToArray(); + + Database.InsertBulk(permissionDtos); + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs index 83035aa37bf5..30d957c2f425 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs @@ -5,17 +5,17 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; [TableName(TableName)] -[PrimaryKey(PrimaryKeyColumnName, AutoIncrement = false)] +[PrimaryKey(INodeDto.NodeIdColumnName, AutoIncrement = false)] [ExplicitColumns] -public class DocumentDto +public class DocumentDto : INodeDto { public const string TableName = Constants.DatabaseSchema.Tables.Document; - public const string PrimaryKeyColumnName = Constants.DatabaseSchema.Columns.NodeIdName; + // Public constants to bind properties between DTOs public const string PublishedColumnName = "published"; - [Column(PrimaryKeyColumnName)] + [Column(INodeDto.NodeIdColumnName)] [PrimaryKeyColumn(AutoIncrement = false)] [ForeignKey(typeof(ContentDto))] public int NodeId { get; set; } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentVersionDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentVersionDto.cs index 1ff5e28e9287..f6e79b55c090 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentVersionDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentVersionDto.cs @@ -5,20 +5,18 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; [TableName(TableName)] -[PrimaryKey(PrimaryKeyColumnName, AutoIncrement = false)] +[PrimaryKey(IContentVersionDto.IdColumnName, AutoIncrement = false)] [ExplicitColumns] -public class DocumentVersionDto +public class DocumentVersionDto : IContentVersionDto { public const string TableName = Constants.DatabaseSchema.Tables.DocumentVersion; - public const string PrimaryKeyColumnName = Constants.DatabaseSchema.Columns.PrimaryKeyNameId; - public const string PublishedColumnName = "published"; private const string TemplateIdColumnName = "templateId"; - [Column(PrimaryKeyColumnName)] + [Column(IContentVersionDto.IdColumnName)] [PrimaryKeyColumn(AutoIncrement = false)] [ForeignKey(typeof(ContentVersionDto))] - [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_id_published", ForColumns = $"{PrimaryKeyColumnName},{PublishedColumnName}", IncludeColumns = TemplateIdColumnName)] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_id_published", ForColumns = $"{IContentVersionDto.IdColumnName},{IContentVersionDto.PublishedColumnName}", IncludeColumns = TemplateIdColumnName)] public int Id { get; set; } [Column(TemplateIdColumnName)] @@ -26,8 +24,8 @@ public class DocumentVersionDto [ForeignKey(typeof(TemplateDto), Column = TemplateDto.NodeIdColumnName)] public int? TemplateId { get; set; } - [Column(PublishedColumnName)] - [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_published", ForColumns = PublishedColumnName, IncludeColumns = $"{PrimaryKeyColumnName},{TemplateIdColumnName}")] + [Column(IContentVersionDto.PublishedColumnName)] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_published", ForColumns = IContentVersionDto.PublishedColumnName, IncludeColumns = $"{IContentVersionDto.IdColumnName},{TemplateIdColumnName}")] public bool Published { get; set; } [ResultColumn] diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ElementCultureVariationDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementCultureVariationDto.cs new file mode 100644 index 000000000000..d7c98f367e71 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementCultureVariationDto.cs @@ -0,0 +1,52 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(TableName)] +[PrimaryKey("id")] +[ExplicitColumns] +internal sealed class ElementCultureVariationDto +{ + public const string TableName = Constants.DatabaseSchema.Tables.ElementCultureVariation; + + [Column("id")] + [PrimaryKeyColumn] + public int Id { get; set; } + + [Column("nodeId")] + [ForeignKey(typeof(NodeDto))] + [Index(IndexTypes.UniqueNonClustered, Name = "IX_" + TableName + "_NodeId", ForColumns = "nodeId,languageId")] + public int NodeId { get; set; } + + [Column("languageId")] + [ForeignKey(typeof(LanguageDto))] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_LanguageId")] + public int LanguageId { get; set; } + + // this is convenient to carry the culture around, but has no db counterpart + [Ignore] + public string? Culture { get; set; } + + // authority on whether a culture has been edited + [Column("edited")] + public bool Edited { get; set; } + + // de-normalized for perfs + // (means there is a current content version culture variation for the language) + [Column("available")] + public bool Available { get; set; } + + // de-normalized for perfs + // (means there is a published content version culture variation for the language) + [Column("published")] + public bool Published { get; set; } + + // de-normalized for perfs + // (when available, copies name from current content version culture variation for the language) + // (otherwise, it's the published one, 'cos we need to have one) + [Column("name")] + [NullSetting(NullSetting = NullSettings.Null)] + public string? Name { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ElementDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementDto.cs new file mode 100644 index 000000000000..e3e19db81501 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementDto.cs @@ -0,0 +1,41 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(TableName)] +[PrimaryKey("nodeId", AutoIncrement = false)] +[ExplicitColumns] +public sealed class ElementDto : INodeDto +{ + internal const string TableName = Constants.DatabaseSchema.Tables.Element; + + [Column("nodeId")] + [PrimaryKeyColumn(AutoIncrement = false)] + [ForeignKey(typeof(ContentDto))] + public int NodeId { get; set; } + + [Column("published")] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_Published")] + public bool Published { get; set; } + + [Column("edited")] + public bool Edited { get; set; } + + [ResultColumn] + [Reference(ReferenceType.OneToOne, ReferenceMemberName = "NodeId")] + public ContentDto ContentDto { get; set; } = null!; + + // although a content has many content versions, + // they can only be loaded one by one (as several content), + // so this here is a OneToOne reference + [ResultColumn] + [Reference(ReferenceType.OneToOne)] + public ElementVersionDto ElementVersionDto { get; set; } = null!; + + // same + [ResultColumn] + [Reference(ReferenceType.OneToOne)] + public ElementVersionDto? PublishedVersionDto { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ElementVersionDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementVersionDto.cs new file mode 100644 index 000000000000..b7bfa6d9b340 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementVersionDto.cs @@ -0,0 +1,27 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(TableName)] +[PrimaryKey("id", AutoIncrement = false)] +[ExplicitColumns] +public sealed class ElementVersionDto : IContentVersionDto +{ + public const string TableName = Constants.DatabaseSchema.Tables.ElementVersion; + + [Column("id")] + [PrimaryKeyColumn(AutoIncrement = false)] + [ForeignKey(typeof(ContentVersionDto))] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_id_published", ForColumns = "id,published")] + public int Id { get; set; } + + [Column("published")] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_published", ForColumns = "published", IncludeColumns = "id")] + public bool Published { get; set; } + + [ResultColumn] + [Reference(ReferenceType.OneToOne)] + public ContentVersionDto ContentVersionDto { get; set; } = null!; +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/IContentVersionDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/IContentVersionDto.cs new file mode 100644 index 000000000000..4b40e931cd48 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/IContentVersionDto.cs @@ -0,0 +1,18 @@ +using NPoco; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +// TODO ELEMENTS: split this into two interfaces - like "IEntityDto" and "IPublishedDto"? +public interface IContentVersionDto +{ + internal const string IdColumnName = Constants.DatabaseSchema.Columns.PrimaryKeyNameId; + + internal const string PublishedColumnName = "published"; + + [Column(IdColumnName)] + int Id { get; } + + [Column(PublishedColumnName)] + bool Published { get; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/INodeDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/INodeDto.cs new file mode 100644 index 000000000000..f75aff04f2d2 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/INodeDto.cs @@ -0,0 +1,12 @@ +using NPoco; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +public interface INodeDto +{ + internal const string NodeIdColumnName = Constants.DatabaseSchema.Columns.NodeIdName; + + [Column(NodeIdColumnName)] + int NodeId { get; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs index 2fbf11fc69af..29fb5af09396 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs @@ -81,6 +81,11 @@ public UserGroupDto() [ForeignKey(typeof(NodeDto), Name = "FK_startMediaId_umbracoNode_id")] public int? StartMediaId { get; set; } + [Column("startElementId")] + [NullSetting(NullSetting = NullSettings.Null)] + [ForeignKey(typeof(NodeDto), Name = "FK_startElementId_umbracoNode_id")] + public int? StartElementId { get; set; } + [ResultColumn] [Reference(ReferenceType.Many, ReferenceMemberName = UserGroup2AppDto.ReferenceMemberName)] public List UserGroup2AppDtos { get; set; } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/UserStartNodeDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/UserStartNodeDto.cs index 9b3ea6daad55..1753a1046094 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/UserStartNodeDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/UserStartNodeDto.cs @@ -20,6 +20,7 @@ public enum StartNodeTypeValue { Content = 1, Media = 2, + Element = 3, } [Column(PrimaryKeyColumnName)] diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs index 8d5cbdf0f1f4..b921bc1dcd6e 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs @@ -72,6 +72,73 @@ public static Content BuildEntity(DocumentDto dto, IContentType? contentType) } } + /// + /// Builds an IElement item from a dto and content type. + /// + // TODO ELEMENTS: refactor and reuse code from BuildEntity(DocumentDto dto, IContentType? contentType) + public static IElement BuildEntity(ElementDto dto, IContentType? contentType) + { + ArgumentNullException.ThrowIfNull(contentType); + + ContentDto contentDto = dto.ContentDto; + NodeDto nodeDto = contentDto.NodeDto; + ElementVersionDto elementVersionDto = dto.ElementVersionDto; + ContentVersionDto contentVersionDto = elementVersionDto.ContentVersionDto; + ElementVersionDto? publishedVersionDto = dto.PublishedVersionDto; + + var content = new Element( + nodeDto.Text ?? throw new ArgumentException("The element did not have a name", nameof(dto)), + contentType); + + try + { + content.DisableChangeTracking(); + + content.Id = dto.NodeId; + content.Key = nodeDto.UniqueId; + content.VersionId = contentVersionDto.Id; + + content.Name = contentVersionDto.Text; + + content.Path = nodeDto.Path; + content.Level = nodeDto.Level; + content.ParentId = nodeDto.ParentId; + content.SortOrder = nodeDto.SortOrder; + content.Trashed = nodeDto.Trashed; + + content.CreatorId = nodeDto.UserId ?? Constants.Security.UnknownUserId; + content.WriterId = contentVersionDto.UserId ?? Constants.Security.UnknownUserId; + content.CreateDate = nodeDto.CreateDate; + content.UpdateDate = contentVersionDto.VersionDate; + + content.Published = dto.Published; + content.Edited = dto.Edited; + + if (publishedVersionDto != null) + { + // We need this to get the proper versionId to match to unpublished values. + // This is only needed if the content has been published before. + content.PublishedVersionId = publishedVersionDto.Id; + if (dto.Published) + { + content.PublishDate = publishedVersionDto.ContentVersionDto.VersionDate; + content.PublishName = publishedVersionDto.ContentVersionDto.Text; + content.PublisherId = publishedVersionDto.ContentVersionDto.UserId; + } + } + + // templates = ignored, managed by the repository + + // reset dirty initial properties (U4-1946) + content.ResetDirtyProperties(false); + return content; + } + finally + { + content.EnableChangeTracking(); + } + } + /// /// Builds a Media item from a dto and content type. /// @@ -187,8 +254,26 @@ public static DocumentDto BuildDto(IContent entity, Guid objectType) return dto; } + /// + /// Builds a dto from an IElement item. + /// + public static ElementDto BuildDto(IElement entity, Guid objectType) + { + ContentDto contentDto = BuildContentDto(entity, objectType); + + var dto = new ElementDto + { + NodeId = entity.Id, + Published = entity.Published, + ContentDto = contentDto, + ElementVersionDto = BuildElementVersionDto(entity, contentDto), + }; + + return dto; + } + public static IEnumerable<(ContentSchedule Model, ContentScheduleDto Dto)> BuildScheduleDto( - IContent entity, + IPublishableContentBase entity, ContentScheduleCollection contentSchedule, ILanguageRepository languageRepository) => contentSchedule.FullSchedule.Select(x => @@ -311,6 +396,21 @@ private static DocumentVersionDto BuildDocumentVersionDto(IContent entity, Conte return dto; } + // always build the current / VersionPk dto + // we're never going to build / save old versions (which are immutable) + private static ElementVersionDto BuildElementVersionDto(IElement entity, ContentDto contentDto) + { + var dto = new ElementVersionDto + { + Id = entity.VersionId, + Published = false, // always building the current, unpublished one + + ContentVersionDto = BuildContentVersionDto(entity, contentDto), + }; + + return dto; + } + private static MediaVersionDto BuildMediaVersionDto(MediaUrlGeneratorCollection mediaUrlGenerators, IMedia entity, ContentDto contentDto) { // try to get a path from the string being stored for media diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs index 1274d958c3da..cdc440a65550 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs @@ -34,6 +34,8 @@ public static IUser BuildEntity( dto.UserStartNodeDtos.Where(x => x.StartNodeType == (int)UserStartNodeDto.StartNodeTypeValue.Content) .Select(x => x.StartNode).ToArray(), dto.UserStartNodeDtos.Where(x => x.StartNodeType == (int)UserStartNodeDto.StartNodeTypeValue.Media) + .Select(x => x.StartNode).ToArray(), + dto.UserStartNodeDtos.Where(x => x.StartNodeType == (int)UserStartNodeDto.StartNodeTypeValue.Element) .Select(x => x.StartNode).ToArray()); try @@ -91,7 +93,7 @@ public static UserDto BuildDto(IUser entity) Avatar = entity.Avatar, EmailConfirmedDate = entity.EmailConfirmedDate, InvitedDate = entity.InvitedDate, - Kind = (short)entity.Kind + Kind = (short)entity.Kind, }; if (entity.StartContentIds is not null) @@ -120,6 +122,19 @@ public static UserDto BuildDto(IUser entity) } } + if (entity.StartElementIds is not null) + { + foreach (var startNodeId in entity.StartElementIds) + { + dto.UserStartNodeDtos.Add(new UserStartNodeDto + { + StartNode = startNodeId, + StartNodeType = (int)UserStartNodeDto.StartNodeTypeValue.Element, + UserId = entity.Id, + }); + } + } + if (entity.HasIdentity) { dto.Id = entity.Id; @@ -128,15 +143,16 @@ public static UserDto BuildDto(IUser entity) return dto; } - private static IReadOnlyUserGroup ToReadOnlyGroup(UserGroupDto group, IDictionary permissionMappers) - { - return new ReadOnlyUserGroup( + private static IReadOnlyUserGroup ToReadOnlyGroup(UserGroupDto group, IDictionary permissionMappers) => + new ReadOnlyUserGroup( group.Id, group.Key, group.Name, + group.Description, group.Icon, group.StartContentId, group.StartMediaId, + group.StartElementId, group.Alias, group.UserGroup2LanguageDtos.Select(x => x.LanguageId), group.UserGroup2AppDtos.Select(x => x.AppAlias).WhereNotNull().ToArray(), @@ -148,12 +164,11 @@ private static IReadOnlyUserGroup ToReadOnlyGroup(UserGroupDto group, IDictionar return mapper.MapFromDto(granularPermission); } - return new UnknownTypeGranularPermission() + return new UnknownTypeGranularPermission { Permission = granularPermission.Permission, - Context = granularPermission.Context + Context = granularPermission.Context, }; })), group.HasAccessToAllLanguages); - } } diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs index 4d715c4ed840..11d08cb8161d 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs @@ -28,6 +28,7 @@ public static IUserGroup BuildEntity(IShortStringHelper shortStringHelper, UserG userGroup.UpdateDate = dto.UpdateDate.EnsureUtc(); userGroup.StartContentId = dto.StartContentId; userGroup.StartMediaId = dto.StartMediaId; + userGroup.StartElementId = dto.StartElementId; userGroup.Permissions = dto.UserGroup2PermissionDtos.Select(x => x.Permission).ToHashSet(); userGroup.HasAccessToAllLanguages = dto.HasAccessToAllLanguages; userGroup.Description = dto.Description; @@ -92,6 +93,7 @@ public static UserGroupDto BuildDto(IUserGroup entity) Icon = entity.Icon, StartMediaId = entity.StartMediaId, StartContentId = entity.StartContentId, + StartElementId = entity.StartElementId, HasAccessToAllLanguages = entity.HasAccessToAllLanguages, }; diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/ElementMapper.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/ElementMapper.cs new file mode 100644 index 000000000000..364f85437edb --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/ElementMapper.cs @@ -0,0 +1,40 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Persistence.Mappers; + +/// +/// Represents a to DTO mapper used to translate the properties of the public api +/// implementation to that of the database's DTO as sql: [tableName].[columnName]. +/// +[MapperFor(typeof(Element))] +[MapperFor(typeof(IElement))] +public sealed class ElementMapper : BaseMapper +{ + public ElementMapper(Lazy sqlContext, MapperConfigurationStore maps) + : base(sqlContext, maps) + { + } + + protected override void DefineMaps() + { + DefineMap(nameof(Element.Id), nameof(NodeDto.NodeId)); + DefineMap(nameof(Element.Key), nameof(NodeDto.UniqueId)); + + DefineMap(nameof(Element.VersionId), nameof(ContentVersionDto.Id)); + DefineMap(nameof(Element.Name), nameof(ContentVersionDto.Text)); + + DefineMap(nameof(Element.ParentId), nameof(NodeDto.ParentId)); + DefineMap(nameof(Element.Level), nameof(NodeDto.Level)); + DefineMap(nameof(Element.Path), nameof(NodeDto.Path)); + DefineMap(nameof(Element.SortOrder), nameof(NodeDto.SortOrder)); + DefineMap(nameof(Element.Trashed), nameof(NodeDto.Trashed)); + + DefineMap(nameof(Element.CreateDate), nameof(NodeDto.CreateDate)); + DefineMap(nameof(Element.CreatorId), nameof(NodeDto.UserId)); + DefineMap(nameof(Element.ContentTypeId), nameof(ContentDto.ContentTypeId)); + + DefineMap(nameof(Element.UpdateDate), nameof(ContentVersionDto.VersionDate)); + DefineMap(nameof(Element.Published), nameof(ElementDto.Published)); + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/MapperCollectionBuilder.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/MapperCollectionBuilder.cs index 12af3490b8a0..3d03013b6c8c 100644 --- a/src/Umbraco.Infrastructure/Persistence/Mappers/MapperCollectionBuilder.cs +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/MapperCollectionBuilder.cs @@ -25,6 +25,7 @@ public MapperCollectionBuilder AddCoreMappers() Add(); Add(); Add(); + Add(); Add(); Add(); Add(); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentVersionRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentVersionRepositoryBase.cs new file mode 100644 index 000000000000..1beafa947e39 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentVersionRepositoryBase.cs @@ -0,0 +1,223 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +internal abstract class ContentVersionRepositoryBase + where TContentDto : INodeDto + where TContentVersionDto : IContentVersionDto +{ + private readonly IScopeAccessor _scopeAccessor; + + protected abstract string ContentDtoTableName { get; } + + protected abstract string ContentVersionDtoTableName { get; } + + public ContentVersionRepositoryBase(IScopeAccessor scopeAccessor) => + _scopeAccessor = scopeAccessor ?? throw new ArgumentNullException(nameof(scopeAccessor)); + + /// + /// + /// Never includes current draft version.
+ /// Never includes current published version.
+ /// Never includes versions marked as "preventCleanup".
+ ///
+ public IReadOnlyCollection GetContentVersionsEligibleForCleanup() + { + IScope? ambientScope = _scopeAccessor.AmbientScope; + if (ambientScope is null) + { + return []; + } + + ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; + Sql query = ambientScope.SqlContext.Sql() + .Select(GetQuotedSelectColumns(syntax)) + .From() + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.Id, right => right.Id) + .LeftJoin() + .On(left => left.Id, right => right.UserId) + .Where(x => !x.Current) // Never delete current draft version + .Where(x => !x.PreventCleanup) // Never delete "pinned" versions + .Where(x => !x.Published); // Never delete published version + + List results = ambientScope.Database.Fetch(query); + EnsureUtcDates(results); + return results; + } + + private string GetQuotedSelectColumns(ISqlSyntaxProvider syntax) => + $@" +{syntax.ColumnWithAlias(ContentVersionDto.TableName, "id", "versionId")}, +{syntax.ColumnWithAlias(ContentDtoTableName, "nodeId", "contentId")}, +{syntax.ColumnWithAlias(ContentDto.TableName, "contentTypeId", "contentTypeId")}, +{syntax.ColumnWithAlias(ContentVersionDto.TableName, "userId", "userId")}, +{syntax.ColumnWithAlias(ContentVersionDto.TableName, "versionDate", "versionDate")}, +{syntax.ColumnWithAlias(ContentVersionDtoTableName,"published", "currentPublishedVersion")}, +{syntax.ColumnWithAlias(ContentVersionDto.TableName, "current", "currentDraftVersion")}, +{syntax.ColumnWithAlias(ContentVersionDto.TableName, "preventCleanup", "preventCleanup")}, +{syntax.ColumnWithAlias(UserDto.TableName, "userName", "username")} +"; + + /// + public IReadOnlyCollection GetCleanupPolicies() + { + if (_scopeAccessor.AmbientScope is null) + { + return []; + } + + Sql query = _scopeAccessor.AmbientScope.SqlContext.Sql(); + + query.Select() + .From(); + + return _scopeAccessor.AmbientScope.Database.Fetch(query); + } + + /// + public IEnumerable GetPagedItemsByContentId(int contentId, long pageIndex, int pageSize, out long totalRecords, int? languageId = null) + { + IScope? ambientScope = _scopeAccessor.AmbientScope; + if (ambientScope is null) + { + totalRecords = 0; + return []; + } + + ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; + Sql query = ambientScope.SqlContext.Sql() + .Select(GetQuotedSelectColumns(syntax)) + .From() + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.Id, right => right.Id) + .LeftJoin() + .On(left => left.Id, right => right.UserId) + .LeftJoin() + .On(left => left.VersionId, right => right.Id) + .Where(x => x.NodeId == contentId); + + // TODO: If there's not a better way to write this then we need a better way to write this. + query = languageId.HasValue + ? query.Where(x => x.LanguageId == languageId.Value) + : query.WhereNull(x => x.LanguageId); + + query = query.OrderByDescending(x => x.Id); + + Page page = + ambientScope.Database.Page(pageIndex + 1, pageSize, query); + + totalRecords = page.TotalItems; + + List results = page.Items; + EnsureUtcDates(results); + return results; + } + + /// + /// + /// Deletes in batches of + /// + public void DeleteVersions(IEnumerable versionIds) + { + if (_scopeAccessor.AmbientScope is null) + { + return; + } + + foreach (IEnumerable group in versionIds.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + var groupedVersionIds = group.ToList(); + + /* Note: We had discussed doing this in a single SQL Command. + * If you can work out how to make that work with SQL CE, let me know! + * Can use test PerformContentVersionCleanup_WithNoKeepPeriods_DeletesEverythingExceptActive to try things out. + */ + + Sql query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.VersionId, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + + query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.VersionId, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + + query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.Id, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + + query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.Id, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + } + } + + /// + public void SetPreventCleanup(int versionId, bool preventCleanup) + { + if (_scopeAccessor.AmbientScope is null) + { + return; + } + + Sql? query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Update(x => x.Set(y => y.PreventCleanup, preventCleanup)) + .Where(x => x.Id == versionId); + + _scopeAccessor.AmbientScope?.Database.Execute(query); + } + + /// + public ContentVersionMeta? Get(int versionId) + { + IScope? ambientScope = _scopeAccessor.AmbientScope; + if (ambientScope is null) + { + return null; + } + + ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; + Sql query = ambientScope.SqlContext.Sql() + .Select(GetQuotedSelectColumns(syntax)) + .From() + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.Id, right => right.Id) + .LeftJoin() + .On(left => left.Id, right => right.UserId) + .Where(x => x.Id == versionId); + + ContentVersionMeta result = ambientScope.Database.Single(query); + result.EnsureUtc(); + return result; + } + + private static void EnsureUtcDates(IEnumerable versions) + { + foreach (ContentVersionMeta version in versions) + { + version.EnsureUtc(); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs index 55d92f0ebd79..e03d440726ec 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs @@ -1450,7 +1450,7 @@ protected override void PersistUpdatedItem(IContent entity) } /// - public void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule) + public void PersistContentSchedule(IPublishableContentBase content, ContentScheduleCollection contentSchedule) { if (content == null) { @@ -1877,12 +1877,12 @@ public IEnumerable GetContentForRelease(DateTime date) } /// - public IDictionary> GetContentSchedulesByIds(int[] documentIds) + public IDictionary> GetContentSchedulesByIds(int[] contentIds) { Sql sql = Sql() .Select() .From() - .WhereIn(contentScheduleDto => contentScheduleDto.NodeId, documentIds); + .WhereIn(contentScheduleDto => contentScheduleDto.NodeId, contentIds); List? contentScheduleDtos = Database.Fetch(sql); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs index ba455bbf8520..d0707feff932 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs @@ -1,221 +1,17 @@ -using System; -using System.Data; -using System.Text; -using NPoco; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Dtos; -using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; using Umbraco.Cms.Infrastructure.Scoping; -using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; -internal sealed class DocumentVersionRepository : IDocumentVersionRepository +internal sealed class DocumentVersionRepository : ContentVersionRepositoryBase, IDocumentVersionRepository { - private readonly IScopeAccessor _scopeAccessor; - - public DocumentVersionRepository(IScopeAccessor scopeAccessor) => - _scopeAccessor = scopeAccessor ?? throw new ArgumentNullException(nameof(scopeAccessor)); - - /// - /// - /// Never includes current draft version.
- /// Never includes current published version.
- /// Never includes versions marked as "preventCleanup".
- ///
- public IReadOnlyCollection GetDocumentVersionsEligibleForCleanup() - { - IScope? ambientScope = _scopeAccessor.AmbientScope; - if (ambientScope is null) - { - return []; - } - - ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; - Sql query = ambientScope.SqlContext.Sql() - .Select(GetQuotedSelectColumns(syntax)) - .From() - .InnerJoin() - .On(left => left.NodeId, right => right.NodeId) - .InnerJoin() - .On(left => left.NodeId, right => right.NodeId) - .InnerJoin() - .On(left => left.Id, right => right.Id) - .LeftJoin() - .On(left => left.Id, right => right.UserId) - .Where(x => !x.Current) // Never delete current draft version - .Where(x => !x.PreventCleanup) // Never delete "pinned" versions - .Where(x => !x.Published); // Never delete published version - - List results = ambientScope.Database.Fetch(query); - EnsureUtcDates(results); - return results; - } - - private string GetQuotedSelectColumns(ISqlSyntaxProvider syntax) => - $@" -{syntax.ColumnWithAlias(ContentVersionDto.TableName, "id", "versionId")}, -{syntax.ColumnWithAlias(DocumentDto.TableName, "nodeId", "contentId")}, -{syntax.ColumnWithAlias(ContentDto.TableName, "contentTypeId", "contentTypeId")}, -{syntax.ColumnWithAlias(ContentVersionDto.TableName, "userId", "userId")}, -{syntax.ColumnWithAlias(ContentVersionDto.TableName, "versionDate", "versionDate")}, -{syntax.ColumnWithAlias(DocumentVersionDto.TableName ,"published", "currentPublishedVersion")}, -{syntax.ColumnWithAlias(ContentVersionDto.TableName, "current", "currentDraftVersion")}, -{syntax.ColumnWithAlias(ContentVersionDto.TableName, "preventCleanup", "preventCleanup")}, -{syntax.ColumnWithAlias(UserDto.TableName, "userName", "username")} -"; - - /// - public IReadOnlyCollection GetCleanupPolicies() - { - if (_scopeAccessor.AmbientScope is null) - { - return []; - } - - Sql query = _scopeAccessor.AmbientScope.SqlContext.Sql(); - - query.Select() - .From(); - - return _scopeAccessor.AmbientScope.Database.Fetch(query); - } - - /// - public IEnumerable GetPagedItemsByContentId(int contentId, long pageIndex, int pageSize, out long totalRecords, int? languageId = null) - { - IScope? ambientScope = _scopeAccessor.AmbientScope; - if (ambientScope is null) - { - totalRecords = 0; - return []; - } - - ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; - Sql query = ambientScope.SqlContext.Sql() - .Select(GetQuotedSelectColumns(syntax)) - .From() - .InnerJoin() - .On(left => left.NodeId, right => right.NodeId) - .InnerJoin() - .On(left => left.NodeId, right => right.NodeId) - .InnerJoin() - .On(left => left.Id, right => right.Id) - .LeftJoin() - .On(left => left.Id, right => right.UserId) - .LeftJoin() - .On(left => left.VersionId, right => right.Id) - .Where(x => x.NodeId == contentId); - - // TODO: If there's not a better way to write this then we need a better way to write this. - query = languageId.HasValue - ? query.Where(x => x.LanguageId == languageId.Value) - : query.WhereNull(x => x.LanguageId); - - query = query.OrderByDescending(x => x.Id); - - Page page = - ambientScope.Database.Page(pageIndex + 1, pageSize, query); - - totalRecords = page.TotalItems; - - List results = page.Items; - EnsureUtcDates(results); - return results; - } - - /// - /// - /// Deletes in batches of - /// - public void DeleteVersions(IEnumerable versionIds) - { - if (_scopeAccessor.AmbientScope is null) - { - return; - } - - foreach (IEnumerable group in versionIds.InGroupsOf(Constants.Sql.MaxParameterCount)) - { - var groupedVersionIds = group.ToList(); - - /* Note: We had discussed doing this in a single SQL Command. - * If you can work out how to make that work with SQL CE, let me know! - * Can use test PerformContentVersionCleanup_WithNoKeepPeriods_DeletesEverythingExceptActive to try things out. - */ - - Sql query = _scopeAccessor.AmbientScope.SqlContext.Sql() - .Delete() - .WhereIn(x => x.VersionId, groupedVersionIds); - _scopeAccessor.AmbientScope.Database.Execute(query); - - query = _scopeAccessor.AmbientScope.SqlContext.Sql() - .Delete() - .WhereIn(x => x.VersionId, groupedVersionIds); - _scopeAccessor.AmbientScope.Database.Execute(query); - - query = _scopeAccessor.AmbientScope.SqlContext.Sql() - .Delete() - .WhereIn(x => x.Id, groupedVersionIds); - _scopeAccessor.AmbientScope.Database.Execute(query); - - query = _scopeAccessor.AmbientScope.SqlContext.Sql() - .Delete() - .WhereIn(x => x.Id, groupedVersionIds); - _scopeAccessor.AmbientScope.Database.Execute(query); - } - } - - /// - public void SetPreventCleanup(int versionId, bool preventCleanup) + public DocumentVersionRepository(IScopeAccessor scopeAccessor) + : base(scopeAccessor) { - if (_scopeAccessor.AmbientScope is null) - { - return; - } - - Sql? query = _scopeAccessor.AmbientScope.SqlContext.Sql() - .Update(x => x.Set(y => y.PreventCleanup, preventCleanup)) - .Where(x => x.Id == versionId); - - _scopeAccessor.AmbientScope?.Database.Execute(query); } - /// - public ContentVersionMeta? Get(int versionId) - { - IScope? ambientScope = _scopeAccessor.AmbientScope; - if (ambientScope is null) - { - return null; - } - - ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; - Sql query = ambientScope.SqlContext.Sql() - .Select(GetQuotedSelectColumns(syntax)) - .From() - .InnerJoin() - .On(left => left.NodeId, right => right.NodeId) - .InnerJoin() - .On(left => left.NodeId, right => right.NodeId) - .InnerJoin() - .On(left => left.Id, right => right.Id) - .LeftJoin() - .On(left => left.Id, right => right.UserId) - .Where(x => x.Id == versionId); - - ContentVersionMeta result = ambientScope.Database.Single(query); - result.EnsureUtc(); - return result; - } + protected override string ContentDtoTableName => DocumentDto.TableName; - private static void EnsureUtcDates(IEnumerable versions) - { - foreach (ContentVersionMeta version in versions) - { - version.EnsureUtc(); - } - } + protected override string ContentVersionDtoTableName => DocumentVersionDto.TableName; } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementContainerRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementContainerRepository.cs new file mode 100644 index 000000000000..39c1196ca711 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementContainerRepository.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Infrastructure.Scoping; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +internal sealed class ElementContainerRepository : EntityContainerRepository, IElementContainerRepository +{ + public ElementContainerRepository( + IScopeAccessor scopeAccessor, + AppCaches cache, + ILogger logger, + IRepositoryCacheVersionService repositoryCacheVersionService, + ICacheSyncService cacheSyncService) + : base( + scopeAccessor, + cache, + logger, + Constants.ObjectTypes.ElementContainer, + repositoryCacheVersionService, + cacheSyncService) + { + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementRepository.cs new file mode 100644 index 000000000000..02759ca30d19 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementRepository.cs @@ -0,0 +1,1706 @@ +using Microsoft.Extensions.Logging; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Factories; +using Umbraco.Cms.Infrastructure.Persistence.Querying; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +/// +/// Represents a repository for doing CRUD operations for . +/// +// TODO ELEMENTS: refactor and reuse code from DocumentRepository (note there is an NPoco issue with generics, so we have to live with a certain amount of code duplication) +public class ElementRepository : ContentRepositoryBase, IElementRepository +{ + private readonly AppCaches _appCaches; + private readonly ElementByGuidReadRepository _elementByGuidReadRepository; + private readonly IContentTypeRepository _contentTypeRepository; + private readonly IJsonSerializer _serializer; + private readonly ITagRepository _tagRepository; + + /// + /// Constructor + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// Lazy property value collection - must be lazy because we have a circular dependency since some property editors + /// require services, yet these services require property editors + /// + public ElementRepository( + IScopeAccessor scopeAccessor, + AppCaches appCaches, + ILogger logger, + ILoggerFactory loggerFactory, + IContentTypeRepository contentTypeRepository, + ITagRepository tagRepository, + ILanguageRepository languageRepository, + IRelationRepository relationRepository, + IRelationTypeRepository relationTypeRepository, + PropertyEditorCollection propertyEditors, + DataValueReferenceFactoryCollection dataValueReferenceFactories, + IDataTypeService dataTypeService, + IJsonSerializer serializer, + IEventAggregator eventAggregator) + : base(scopeAccessor, appCaches, logger, languageRepository, relationRepository, relationTypeRepository, + propertyEditors, dataValueReferenceFactories, dataTypeService, eventAggregator) + { + _contentTypeRepository = + contentTypeRepository ?? throw new ArgumentNullException(nameof(contentTypeRepository)); + _tagRepository = tagRepository ?? throw new ArgumentNullException(nameof(tagRepository)); + _serializer = serializer; + _appCaches = appCaches; + _elementByGuidReadRepository = new ElementByGuidReadRepository(this, scopeAccessor, appCaches, + loggerFactory.CreateLogger()); + } + + protected override ElementRepository This => this; + + /// + /// Default is to always ensure all elements have unique names + /// + protected virtual bool EnsureUniqueNaming { get; } = true; + + /// + public ContentScheduleCollection GetContentSchedule(int contentId) + { + var result = new ContentScheduleCollection(); + + List? scheduleDtos = Database.Fetch(Sql() + .Select() + .From() + .Where(x => x.NodeId == contentId)); + + foreach (ContentScheduleDto? scheduleDto in scheduleDtos) + { + result.Add(new ContentSchedule(scheduleDto.Id, + LanguageRepository.GetIsoCodeById(scheduleDto.LanguageId) ?? string.Empty, + scheduleDto.Date, + scheduleDto.Action == ContentScheduleAction.Release.ToString() + ? ContentScheduleAction.Release + : ContentScheduleAction.Expire)); + } + + return result; + } + + protected override string ApplySystemOrdering(ref Sql sql, Ordering ordering) + { + // note: 'updater' is the user who created the latest draft version, + // we don't have an 'updater' per culture (should we?) + if (ordering.OrderBy.InvariantEquals("updater")) + { + Sql joins = Sql() + .InnerJoin("updaterUser") + .On((version, user) => version.UserId == user.Id, + aliasRight: "updaterUser"); + + // see notes in ApplyOrdering: the field MUST be selected + aliased + sql = Sql( + InsertBefore(sql, "FROM", + ", " + SqlSyntax.GetFieldName(x => x.UserName, "updaterUser") + " AS ordering "), + sql.Arguments); + + sql = InsertJoins(sql, joins); + + return "ordering"; + } + + if (ordering.OrderBy.InvariantEquals("published")) + { + // no culture, assume invariant and simply order by published. + if (ordering.Culture.IsNullOrWhiteSpace()) + { + return SqlSyntax.GetFieldName(x => x.Published); + } + + // invariant: left join will yield NULL and we must use pcv to determine published + // variant: left join may yield NULL or something, and that determines published + + Sql joins = Sql() + .InnerJoin("ctype").On( + (content, contentType) => content.ContentTypeId == contentType.NodeId, aliasRight: "ctype") + // left join on optional culture variation + //the magic "[[[ISOCODE]]]" parameter value will be replaced in ContentRepositoryBase.GetPage() by the actual ISO code + .LeftJoin(nested => + nested.InnerJoin("langp").On( + (ccv, lang) => ccv.LanguageId == lang.Id && lang.IsoCode == "[[[ISOCODE]]]", "ccvp", + "langp"), + "ccvp") + .On((version, ccv) => version.Id == ccv.VersionId, + "pcv", "ccvp"); + + sql = InsertJoins(sql, joins); + + // see notes in ApplyOrdering: the field MUST be selected + aliased, and we cannot have + // the whole CASE fragment in ORDER BY due to it not being detected by NPoco + var sqlText = InsertBefore(sql.SQL, "FROM", + + // when invariant, ie 'variations' does not have the culture flag (value 1), it should be safe to simply use the published flag on umbracoElement, + // otherwise check if there's a version culture variation for the lang, via ccv.id + $", (CASE WHEN (ctype.variations & 1) = 0 THEN ({SqlSyntax.GetFieldName(x => x.Published)}) ELSE (CASE WHEN ccvp.id IS NULL THEN 0 ELSE 1 END) END) AS ordering "); // trailing space is important! + + sql = Sql(sqlText, sql.Arguments); + + return "ordering"; + } + + return base.ApplySystemOrdering(ref sql, ordering); + } + + private IEnumerable MapDtosToContent(List dtos, + bool withCache = false, + bool loadProperties = true, + bool loadVariants = true) + { + var temps = new List>(); + var contentTypes = new Dictionary(); + + var content = new IElement[dtos.Count]; + + for (var i = 0; i < dtos.Count; i++) + { + ElementDto dto = dtos[i]; + + if (withCache) + { + // if the cache contains the (proper version of the) item, use it + IElement? cached = + IsolatedCache.GetCacheItem(RepositoryCacheKeys.GetKey(dto.NodeId)); + if (cached != null && cached.VersionId == dto.ElementVersionDto.ContentVersionDto.Id) + { + content[i] = cached; + continue; + } + } + + // else, need to build it + + // get the content type - the repository is full cache *but* still deep-clones + // whatever comes out of it, so use our own local index here to avoid this + var contentTypeId = dto.ContentDto.ContentTypeId; + if (contentTypes.TryGetValue(contentTypeId, out IContentType? contentType) == false) + { + contentTypes[contentTypeId] = contentType = _contentTypeRepository.Get(contentTypeId); + } + + IElement c = content[i] = ContentBaseFactory.BuildEntity(dto, contentType); + + // need temps, for properties, templates and variations + var versionId = dto.ElementVersionDto.Id; + var publishedVersionId = dto.Published ? dto.PublishedVersionDto!.Id : 0; + var temp = new TempContent(dto.NodeId, versionId, publishedVersionId, contentType, c); + + temps.Add(temp); + } + + IDictionary? properties = null; + if (loadProperties) + { + // load all properties for all elements from database in 1 query - indexed by version id + properties = GetPropertyCollections(temps); + } + + // assign templates and properties + foreach (TempContent temp in temps) + { + // set properties + if (loadProperties) + { + if (properties?.ContainsKey(temp.VersionId) ?? false) + { + temp.Content!.Properties = properties[temp.VersionId]; + } + else + { + throw new InvalidOperationException($"No property data found for version: '{temp.VersionId}'."); + } + } + } + + if (loadVariants) + { + // set variations, if varying + temps = temps.Where(x => x.ContentType?.VariesByCulture() ?? false).ToList(); + if (temps.Count > 0) + { + // load all variations for all elements from database, in one query + IDictionary> contentVariations = GetContentVariations(temps); + IDictionary> elementVariations = GetElementVariations(temps); + foreach (TempContent temp in temps) + { + SetVariations(temp.Content, contentVariations, elementVariations); + } + } + } + + + foreach (IElement c in content) + { + c.ResetDirtyProperties(false); // reset dirty initial properties (U4-1946) + } + + return content; + } + + private IElement MapDtoToContent(ElementDto dto) + { + IContentType? contentType = _contentTypeRepository.Get(dto.ContentDto.ContentTypeId); + IElement content = ContentBaseFactory.BuildEntity(dto, contentType); + + try + { + content.DisableChangeTracking(); + + // get properties - indexed by version id + var versionId = dto.ElementVersionDto.Id; + + // TODO: shall we get published properties or not? + //var publishedVersionId = dto.Published ? dto.PublishedVersionDto.Id : 0; + var publishedVersionId = dto.PublishedVersionDto?.Id ?? 0; + + var temp = new TempContent(dto.NodeId, versionId, publishedVersionId, contentType); + var ltemp = new List> {temp}; + IDictionary properties = GetPropertyCollections(ltemp); + content.Properties = properties[dto.ElementVersionDto.Id]; + + // set variations, if varying + if (contentType?.VariesByCulture() ?? false) + { + IDictionary> contentVariations = GetContentVariations(ltemp); + IDictionary> elementVariations = GetElementVariations(ltemp); + SetVariations(content, contentVariations, elementVariations); + } + + // reset dirty initial properties (U4-1946) + content.ResetDirtyProperties(false); + return content; + } + finally + { + content.EnableChangeTracking(); + } + } + + private void SetVariations(IElement? element, IDictionary> contentVariations, + IDictionary> elementVariations) + { + if (element is null) + { + return; + } + + if (contentVariations.TryGetValue(element.VersionId, out List? contentVariation)) + { + foreach (ContentVariation v in contentVariation) + { + element.SetCultureInfo(v.Culture, v.Name, v.Date); + } + } + + if (element.PublishedState is PublishedState.Published && element.PublishedVersionId > 0 && contentVariations.TryGetValue(element.PublishedVersionId, out contentVariation)) + { + foreach (ContentVariation v in contentVariation) + { + element.SetPublishInfo(v.Culture, v.Name, v.Date); + } + } + + if (elementVariations.TryGetValue(element.Id, out List? elementVariation)) + { + element.SetCultureEdited(elementVariation.Where(x => x.Edited).Select(x => x.Culture)); + } + } + + private IDictionary> GetContentVariations(List> temps) + where T : class, IContentBase + { + var versions = new List(); + foreach (TempContent temp in temps) + { + versions.Add(temp.VersionId); + if (temp.PublishedVersionId > 0) + { + versions.Add(temp.PublishedVersionId); + } + } + + if (versions.Count == 0) + { + return new Dictionary>(); + } + + IEnumerable dtos = + Database.FetchByGroups(versions, Constants.Sql.MaxParameterCount, + batch + => Sql() + .Select() + .From() + .WhereIn(x => x.VersionId, batch)); + + var variations = new Dictionary>(); + + foreach (ContentVersionCultureVariationDto dto in dtos) + { + if (!variations.TryGetValue(dto.VersionId, out List? variation)) + { + variations[dto.VersionId] = variation = new List(); + } + + variation.Add(new ContentVariation + { + Culture = LanguageRepository.GetIsoCodeById(dto.LanguageId), Name = dto.Name, Date = dto.UpdateDate + }); + } + + return variations; + } + + private IDictionary> GetElementVariations(List> temps) + where T : class, IContentBase + { + IEnumerable ids = temps.Select(x => x.Id); + + IEnumerable dtos = Database.FetchByGroups(ids, + Constants.Sql.MaxParameterCount, batch => + Sql() + .Select() + .From() + .WhereIn(x => x.NodeId, batch)); + + var variations = new Dictionary>(); + + foreach (ElementCultureVariationDto dto in dtos) + { + if (!variations.TryGetValue(dto.NodeId, out List? variation)) + { + variations[dto.NodeId] = variation = new List(); + } + + variation.Add(new ElementVariation + { + Culture = LanguageRepository.GetIsoCodeById(dto.LanguageId), Edited = dto.Edited + }); + } + + return variations; + } + + private IEnumerable GetContentVariationDtos(IElement element, bool publishing) + { + if (element.CultureInfos is not null) + { + // create dtos for the 'current' (non-published) version, all cultures + // ReSharper disable once UseDeconstruction + foreach (ContentCultureInfos cultureInfo in element.CultureInfos) + { + yield return new ContentVersionCultureVariationDto + { + VersionId = element.VersionId, + LanguageId = + LanguageRepository.GetIdByIsoCode(cultureInfo.Culture) ?? + throw new InvalidOperationException("Not a valid culture."), + Culture = cultureInfo.Culture, + Name = cultureInfo.Name, + UpdateDate = + element.GetUpdateDate(cultureInfo.Culture) ?? DateTime.MinValue // we *know* there is a value + }; + } + } + + // if not publishing, we're just updating the 'current' (non-published) version, + // so there are no DTOs to create for the 'published' version which remains unchanged + if (!publishing) + { + yield break; + } + + if (element.PublishCultureInfos is not null) + { + // create dtos for the 'published' version, for published cultures (those having a name) + // ReSharper disable once UseDeconstruction + foreach (ContentCultureInfos cultureInfo in element.PublishCultureInfos) + { + yield return new ContentVersionCultureVariationDto + { + VersionId = element.PublishedVersionId, + LanguageId = + LanguageRepository.GetIdByIsoCode(cultureInfo.Culture) ?? + throw new InvalidOperationException("Not a valid culture."), + Culture = cultureInfo.Culture, + Name = cultureInfo.Name, + UpdateDate = + element.GetPublishDate(cultureInfo.Culture) ?? DateTime.MinValue // we *know* there is a value + }; + } + } + } + + private IEnumerable GetElementVariationDtos(IElement element, + HashSet editedCultures) + { + IEnumerable + allCultures = element.AvailableCultures.Union(element.PublishedCultures); // union = distinct + foreach (var culture in allCultures) + { + var dto = new ElementCultureVariationDto + { + NodeId = element.Id, + LanguageId = + LanguageRepository.GetIdByIsoCode(culture) ?? + throw new InvalidOperationException("Not a valid culture."), + Culture = culture, + Name = element.GetCultureName(culture) ?? element.GetPublishName(culture), + Available = element.IsCultureAvailable(culture), + Published = element.IsCulturePublished(culture), + // note: can't use IsCultureEdited at that point - hasn't been updated yet - see PersistUpdatedItem + Edited = element.IsCultureAvailable(culture) && + (!element.IsCulturePublished(culture) || + (editedCultures != null && editedCultures.Contains(culture))) + }; + + yield return dto; + } + } + + private class ContentVariation + { + public string? Culture { get; set; } + public string? Name { get; set; } + public DateTime Date { get; set; } + } + + private class ElementVariation + { + public string? Culture { get; set; } + public bool Edited { get; set; } + } + + #region Repository Base + + protected override Guid NodeObjectTypeId => Constants.ObjectTypes.Element; + + protected override IElement? PerformGet(int id) + { + Sql sql = GetBaseQuery(QueryType.Single) + .Where(x => x.NodeId == id) + .SelectTop(1); + + ElementDto? dto = Database.Fetch(sql).FirstOrDefault(); + return dto == null + ? null + : MapDtoToContent(dto); + } + + protected override IEnumerable PerformGetAll(params int[]? ids) + { + Sql sql = GetBaseQuery(QueryType.Many); + + if (ids?.Any() ?? false) + { + sql.WhereIn(x => x.NodeId, ids); + } + + return MapDtosToContent(Database.Fetch(sql)); + } + + protected override IEnumerable PerformGetByQuery(IQuery query) + { + Sql sqlClause = GetBaseQuery(QueryType.Many); + + var translator = new SqlTranslator(sqlClause, query); + Sql sql = translator.Translate(); + + AddGetByQueryOrderBy(sql); + + return MapDtosToContent(Database.Fetch(sql)); + } + + private void AddGetByQueryOrderBy(Sql sql) => + sql + .OrderBy(x => x.Level) + .OrderBy(x => x.SortOrder); + + protected override Sql GetBaseQuery(QueryType queryType) => GetBaseQuery(queryType, true); + + // gets the COALESCE expression for variant/invariant name + private string VariantNameSqlExpression + => SqlContext.VisitDto((ccv, node) => ccv.Name ?? node.Text, "ccv") + .Sql; + + protected Sql GetBaseQuery(QueryType queryType, bool current) + { + Sql sql = SqlContext.Sql(); + + switch (queryType) + { + case QueryType.Count: + sql = sql.SelectCount(); + break; + case QueryType.Ids: + sql = sql.Select(x => x.NodeId); + break; + case QueryType.Single: + case QueryType.Many: + // R# may flag this ambiguous and red-squiggle it, but it is not + sql = sql.Select(r => + r.Select(elementDto => elementDto.ContentDto, r1 => + r1.Select(contentDto => contentDto.NodeDto)) + .Select(elementDto => elementDto.ElementVersionDto, r1 => + r1.Select(elementVersionDto => elementVersionDto.ContentVersionDto)) + .Select(elementDto => elementDto.PublishedVersionDto, "pdv", r1 => + r1.Select(elementVersionDto => elementVersionDto!.ContentVersionDto, "pcv"))) + + // select the variant name, coalesce to the invariant name, as "variantName" + .AndSelect(VariantNameSqlExpression + " AS variantName"); + break; + } + + sql + .From() + .InnerJoin().On(left => left.NodeId, right => right.NodeId) + .InnerJoin().On(left => left.NodeId, right => right.NodeId) + + // inner join on mandatory edited version + .InnerJoin() + .On((left, right) => left.NodeId == right.NodeId) + .InnerJoin() + .On((left, right) => left.Id == right.Id) + + // left join on optional published version + .LeftJoin(nested => + nested.InnerJoin("pdv") + .On((left, right) => left.Id == right.Id && right.Published, + "pcv", "pdv"), "pcv") + .On((left, right) => left.NodeId == right.NodeId, aliasRight: "pcv") + + // TODO: should we be joining this when the query type is not single/many? + // left join on optional culture variation + //the magic "[[[ISOCODE]]]" parameter value will be replaced in ContentRepositoryBase.GetPage() by the actual ISO code + .LeftJoin(nested => + nested.InnerJoin("lang").On( + (ccv, lang) => ccv.LanguageId == lang.Id && lang.IsoCode == "[[[ISOCODE]]]", "ccv", "lang"), "ccv") + .On((version, ccv) => version.Id == ccv.VersionId, + aliasRight: "ccv"); + + sql + .Where(x => x.NodeObjectType == NodeObjectTypeId); + + // this would ensure we don't get the published version - keep for reference + //sql + // .WhereAny( + // x => x.Where((x1, x2) => x1.Id != x2.Id, alias2: "pcv"), + // x => x.WhereNull(x1 => x1.Id, "pcv") + // ); + + if (current) + { + sql.Where(x => x.Current); // always get the current version + } + + return sql; + } + + protected override Sql GetBaseQuery(bool isCount) => + GetBaseQuery(isCount ? QueryType.Count : QueryType.Single); + + // ah maybe not, that what's used for eg Exists in base repo + protected override string GetBaseWhereClause() => $"{Constants.DatabaseSchema.Tables.Node}.id = @id"; + + protected override IEnumerable GetDeleteClauses() + { + var list = new List + { + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentSchedule + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.User2NodeNotify + " WHERE nodeId = @id", + // TODO ELEMENTS: include these if applicable, or clean up if not + // "DELETE FROM " + Constants.DatabaseSchema.Tables.UserGroup2GranularPermission + " WHERE uniqueId IN (SELECT uniqueId FROM umbracoNode WHERE id = @id)", + // "DELETE FROM " + Constants.DatabaseSchema.Tables.UserStartNode + " WHERE startNode = @id", + // "UPDATE " + Constants.DatabaseSchema.Tables.UserGroup + + // " SET startContentId = NULL WHERE startContentId = @id", + // "DELETE FROM " + Constants.DatabaseSchema.Tables.Relation + " WHERE parentId = @id", + // "DELETE FROM " + Constants.DatabaseSchema.Tables.Relation + " WHERE childId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.TagRelationship + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.Element + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ElementCultureVariation + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ElementVersion + " WHERE id IN (SELECT id FROM " + + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)", + "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyData + " WHERE versionId IN (SELECT id FROM " + + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersionCultureVariation + + " WHERE versionId IN (SELECT id FROM " + Constants.DatabaseSchema.Tables.ContentVersion + + " WHERE nodeId = @id)", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.Content + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.Node + " WHERE id = @id", + }; + return list; + } + + #endregion + + #region Versions + + public override IEnumerable GetAllVersions(int nodeId) + { + Sql sql = GetBaseQuery(QueryType.Many, false) + .Where(x => x.NodeId == nodeId) + .OrderByDescending(x => x.Current) + .AndByDescending(x => x.VersionDate); + + return MapDtosToContent(Database.Fetch(sql), true); + } + + // TODO: This method needs to return a readonly version of IContent! The content returned + // from this method does not contain all of the data required to re-persist it and if that + // is attempted some odd things will occur. + // Either we create an IContentReadOnly (which ultimately we should for vNext so we can + // differentiate between methods that return entities that can be re-persisted or not), or + // in the meantime to not break API compatibility, we can add a property to IContentBase + // (or go further and have it on IUmbracoEntity): "IsReadOnly" and if that is true we throw + // an exception if that entity is passed to a Save method. + // Ideally we return "Slim" versions of content for all sorts of methods here and in ContentService. + // Perhaps another non-breaking alternative is to have new services like IContentServiceReadOnly + // which can return IContentReadOnly. + // We have the ability with `MapDtosToContent` to reduce the amount of data looked up for a + // content item. Ideally for paged data that populates list views, these would be ultra slim + // content items, there's no reason to populate those with really anything apart from property data, + // but until we do something like the above, we can't do that since it would be breaking and unclear. + public override IEnumerable GetAllVersionsSlim(int nodeId, int skip, int take) + { + Sql sql = GetBaseQuery(QueryType.Many, false) + .Where(x => x.NodeId == nodeId) + .OrderByDescending(x => x.Current) + .AndByDescending(x => x.VersionDate); + + var pageIndex = skip / take; + + return MapDtosToContent(Database.Page(pageIndex + 1, take, sql).Items, true, + // load bare minimum, need variants though since this is used to rollback with variants + false, false); + } + + public override IElement? GetVersion(int versionId) + { + Sql sql = GetBaseQuery(QueryType.Single, false) + .Where(x => x.Id == versionId); + + ElementDto? dto = Database.Fetch(sql).FirstOrDefault(); + return dto == null ? null : MapDtoToContent(dto); + } + + // deletes a specific version + public override void DeleteVersion(int versionId) + { + // TODO: test object node type? + + // get the version we want to delete + SqlTemplate template = SqlContext.Templates.Get("Umbraco.Core.ElementRepository.GetVersion", tsql => + tsql.Select() + .AndSelect() + .From() + .InnerJoin() + .On((c, d) => c.Id == d.Id) + .Where(x => x.Id == SqlTemplate.Arg("versionId")) + ); + ElementVersionDto? versionDto = + Database.Fetch(template.Sql(new {versionId})).FirstOrDefault(); + + // nothing to delete + if (versionDto == null) + { + return; + } + + // don't delete the current or published version + if (versionDto.ContentVersionDto.Current) + { + throw new InvalidOperationException("Cannot delete the current version."); + } + + if (versionDto.Published) + { + throw new InvalidOperationException("Cannot delete the published version."); + } + + PerformDeleteVersion(versionDto.ContentVersionDto.NodeId, versionId); + } + + // deletes all versions of an entity, older than a date. + public override void DeleteVersions(int nodeId, DateTime versionDate) + { + // TODO: test object node type? + + // get the versions we want to delete, excluding the current one + SqlTemplate template = SqlContext.Templates.Get("Umbraco.Core.ElementRepository.GetVersions", tsql => + tsql.Select() + .From() + .InnerJoin() + .On((c, d) => c.Id == d.Id) + .Where(x => + x.NodeId == SqlTemplate.Arg("nodeId") && !x.Current && + x.VersionDate < SqlTemplate.Arg("versionDate")) + .Where(x => !x.Published) + ); + List? versionDtos = + Database.Fetch(template.Sql(new {nodeId, versionDate})); + foreach (ContentVersionDto? versionDto in versionDtos) + { + PerformDeleteVersion(versionDto.NodeId, versionDto.Id); + } + } + + protected override void PerformDeleteVersion(int id, int versionId) + { + Database.Delete("WHERE versionId = @versionId", new {versionId}); + Database.Delete("WHERE versionId = @versionId", new {versionId}); + Database.Delete("WHERE id = @versionId", new {versionId}); + Database.Delete("WHERE id = @versionId", new {versionId}); + } + + #endregion + + #region Persist + + protected override void PersistNewItem(IElement entity) + { + entity.AddingEntity(); + + var publishing = entity.PublishedState == PublishedState.Publishing; + + // sanitize names + SanitizeNames(entity, publishing); + + // ensure that strings don't contain characters that are invalid in xml + // TODO: do we really want to keep doing this here? + entity.SanitizeEntityPropertiesForXmlStorage(); + + // create the dto + ElementDto dto = ContentBaseFactory.BuildDto(entity, NodeObjectTypeId); + + // derive path and level from parent + NodeDto parent = GetParentNodeDto(entity.ParentId); + var level = parent.Level + 1; + + var sortOrderExists = SortorderExists(entity.ParentId, entity.SortOrder); + // if the sortorder of the entity already exists get a new one, else use the sortOrder of the entity + var sortOrder = sortOrderExists ? GetNewChildSortOrder(entity.ParentId, 0) : entity.SortOrder; + + // persist the node dto + NodeDto nodeDto = dto.ContentDto.NodeDto; + nodeDto.Path = parent.Path; + nodeDto.Level = Convert.ToInt16(level); + nodeDto.SortOrder = sortOrder; + + // see if there's a reserved identifier for this unique id + // and then either update or insert the node dto + var id = GetReservedId(nodeDto.UniqueId); + if (id > 0) + { + nodeDto.NodeId = id; + } + else + { + Database.Insert(nodeDto); + } + + nodeDto.Path = string.Concat(parent.Path, ",", nodeDto.NodeId); + nodeDto.ValidatePathWithException(); + Database.Update(nodeDto); + + // update entity + entity.Id = nodeDto.NodeId; + entity.Path = nodeDto.Path; + entity.SortOrder = sortOrder; + entity.Level = level; + + // persist the content dto + ContentDto contentDto = dto.ContentDto; + contentDto.NodeId = nodeDto.NodeId; + Database.Insert(contentDto); + + // persist the content version dto + ContentVersionDto contentVersionDto = dto.ElementVersionDto.ContentVersionDto; + contentVersionDto.NodeId = nodeDto.NodeId; + contentVersionDto.Current = !publishing; + Database.Insert(contentVersionDto); + entity.VersionId = contentVersionDto.Id; + + // persist the element version dto + ElementVersionDto elementVersionDto = dto.ElementVersionDto; + elementVersionDto.Id = entity.VersionId; + if (publishing) + { + elementVersionDto.Published = true; + } + + Database.Insert(elementVersionDto); + + // and again in case we're publishing immediately + if (publishing) + { + entity.PublishedVersionId = entity.VersionId; + contentVersionDto.Id = 0; + contentVersionDto.Current = true; + contentVersionDto.Text = entity.Name; + Database.Insert(contentVersionDto); + entity.VersionId = contentVersionDto.Id; + + elementVersionDto.Id = entity.VersionId; + elementVersionDto.Published = false; + Database.Insert(elementVersionDto); + } + + // persist the property data + IEnumerable propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, + entity.VersionId, entity.PublishedVersionId, entity.Properties, LanguageRepository, out var edited, + out HashSet? editedCultures); + foreach (PropertyDataDto propertyDataDto in propertyDataDtos) + { + Database.Insert(propertyDataDto); + } + + // if !publishing, we may have a new name != current publish name, + // also impacts 'edited' + if (!publishing && entity.PublishName != entity.Name) + { + edited = true; + } + + // persist the element dto + // at that point, when publishing, the entity still has its old Published value + // so we need to explicitly update the dto to persist the correct value + if (entity.PublishedState == PublishedState.Publishing) + { + dto.Published = true; + } + + dto.NodeId = nodeDto.NodeId; + entity.Edited = dto.Edited = !dto.Published || edited; // if not published, always edited + Database.Insert(dto); + + // persist the variations + if (entity.ContentType.VariesByCulture()) + { + // names also impact 'edited' + // ReSharper disable once UseDeconstruction + foreach (ContentCultureInfos cultureInfo in entity.CultureInfos!) + { + if (cultureInfo.Name != entity.GetPublishName(cultureInfo.Culture)) + { + (editedCultures ??= new HashSet(StringComparer.OrdinalIgnoreCase)).Add(cultureInfo.Culture); + } + } + + // refresh content + entity.SetCultureEdited(editedCultures!); + + // bump dates to align cultures to version + entity.AdjustDates(contentVersionDto.VersionDate, publishing); + + // insert content variations + Database.BulkInsertRecords(GetContentVariationDtos(entity, publishing)); + + // insert element variations + Database.BulkInsertRecords(GetElementVariationDtos(entity, editedCultures!)); + } + + // trigger here, before we reset Published etc + // TODO ELEMENTS: implement + // OnUowRefreshedEntity(new ContentRefreshNotification(entity, new EventMessages())); + + // flip the entity's published property + // this also flips its published state + // note: what depends on variations (eg PublishNames) is managed directly by the content + if (entity.PublishedState == PublishedState.Publishing) + { + entity.Published = true; + entity.PublisherId = entity.WriterId; + entity.PublishName = entity.Name; + entity.PublishDate = entity.UpdateDate; + + SetEntityTags(entity, _tagRepository, _serializer); + } + else if (entity.PublishedState == PublishedState.Unpublishing) + { + entity.Published = false; + entity.PublisherId = null; + entity.PublishName = null; + entity.PublishDate = null; + + ClearEntityTags(entity, _tagRepository); + } + + PersistRelations(entity); + + entity.ResetDirtyProperties(); + + // troubleshooting + //if (Database.ExecuteScalar($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.ElementVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.ElementVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE published=1 AND nodeId=" + content.Id) > 1) + //{ + // Debugger.Break(); + // throw new Exception("oops"); + //} + //if (Database.ExecuteScalar($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.ElementVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.ElementVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE [current]=1 AND nodeId=" + content.Id) > 1) + //{ + // Debugger.Break(); + // throw new Exception("oops"); + //} + } + + protected override void PersistUpdatedItem(IElement entity) + { + var isEntityDirty = entity.IsDirty(); + var editedSnapshot = entity.Edited; + + // check if we need to make any database changes at all + if ((entity.PublishedState == PublishedState.Published || entity.PublishedState == PublishedState.Unpublished) + && !isEntityDirty && !entity.IsAnyUserPropertyDirty()) + { + return; // no change to save, do nothing, don't even update dates + } + + // whatever we do, we must check that we are saving the current version + ContentVersionDto? version = Database.Fetch(SqlContext.Sql().Select() + .From().Where(x => x.Id == entity.VersionId)).FirstOrDefault(); + if (version == null || !version.Current) + { + throw new InvalidOperationException("Cannot save a non-current version."); + } + + // update + entity.UpdatingEntity(); + + // Check if this entity is being moved as a descendant as part of a bulk moving operations. + // In this case we can bypass a lot of the below operations which will make this whole operation go much faster. + // When moving we don't need to create new versions, etc... because we cannot roll this operation back anyways. + var isMoving = entity.IsMoving(); + // TODO: I'm sure we can also detect a "Copy" (of a descendant) operation and probably perform similar checks below. + // There is probably more stuff that would be required for copying but I'm sure not all of this logic would be, we could more than likely boost + // copy performance by 95% just like we did for Move + + + var publishing = entity.PublishedState == PublishedState.Publishing; + + if (!isMoving) + { + // check if we need to create a new version + if (publishing && entity.PublishedVersionId > 0) + { + // published version is not published anymore + Database.Execute(Sql().Update(u => u.Set(x => x.Published, false)) + .Where(x => x.Id == entity.PublishedVersionId)); + } + + // sanitize names + SanitizeNames(entity, publishing); + + // ensure that strings don't contain characters that are invalid in xml + // TODO: do we really want to keep doing this here? + entity.SanitizeEntityPropertiesForXmlStorage(); + + // if parent has changed, get path, level and sort order + if (entity.IsPropertyDirty("ParentId")) + { + NodeDto parent = GetParentNodeDto(entity.ParentId); + entity.Path = string.Concat(parent.Path, ",", entity.Id); + entity.Level = parent.Level + 1; + entity.SortOrder = GetNewChildSortOrder(entity.ParentId, 0); + } + } + + // create the dto + ElementDto dto = ContentBaseFactory.BuildDto(entity, NodeObjectTypeId); + + // update the node dto + NodeDto nodeDto = dto.ContentDto.NodeDto; + nodeDto.ValidatePathWithException(); + Database.Update(nodeDto); + + if (!isMoving) + { + // update the content dto + Database.Update(dto.ContentDto); + + // update the content & element version dtos + ContentVersionDto contentVersionDto = dto.ElementVersionDto.ContentVersionDto; + ElementVersionDto elementVersionDto = dto.ElementVersionDto; + if (publishing) + { + elementVersionDto.Published = true; // now published + contentVersionDto.Current = false; // no more current + } + + // Ensure existing version retains current preventCleanup flag (both saving and publishing). + contentVersionDto.PreventCleanup = version.PreventCleanup; + + Database.Update(contentVersionDto); + Database.Update(elementVersionDto); + + // and, if publishing, insert new content & element version dtos + if (publishing) + { + entity.PublishedVersionId = entity.VersionId; + + contentVersionDto.Id = 0; // want a new id + contentVersionDto.Current = true; // current version + contentVersionDto.Text = entity.Name; + contentVersionDto.PreventCleanup = false; // new draft version disregards prevent cleanup flag + Database.Insert(contentVersionDto); + entity.VersionId = elementVersionDto.Id = contentVersionDto.Id; // get the new id + + elementVersionDto.Published = false; // non-published version + Database.Insert(elementVersionDto); + } + + // replace the property data (rather than updating) + // only need to delete for the version that existed, the new version (if any) has no property data yet + var versionToDelete = publishing ? entity.PublishedVersionId : entity.VersionId; + + // insert property data + ReplacePropertyValues(entity, versionToDelete, publishing ? entity.PublishedVersionId : 0, out var edited, + out HashSet? editedCultures); + + // if !publishing, we may have a new name != current publish name, + // also impacts 'edited' + if (!publishing && entity.PublishName != entity.Name) + { + edited = true; + } + + // To establish the new value of "edited" we compare all properties publishedValue to editedValue and look + // for differences. + // + // If we SaveAndPublish but the publish fails (e.g. already scheduled for release) + // we have lost the publishedValue on IElement (in memory vs database) so we cannot correctly make that comparison. + // + // This is a slight change to behaviour, historically a publish, followed by change & save, followed by undo change & save + // would change edited back to false. + if (!publishing && editedSnapshot) + { + edited = true; + } + + if (entity.ContentType.VariesByCulture()) + { + // names also impact 'edited' + // ReSharper disable once UseDeconstruction + foreach (ContentCultureInfos cultureInfo in entity.CultureInfos!) + { + if (cultureInfo.Name != entity.GetPublishName(cultureInfo.Culture)) + { + edited = true; + (editedCultures ??= new HashSet(StringComparer.OrdinalIgnoreCase)).Add(cultureInfo + .Culture); + + // TODO: change tracking + // at the moment, we don't do any dirty tracking on property values, so we don't know whether the + // culture has just been edited or not, so we don't update its update date - that date only changes + // when the name is set, and it all works because the controller does it - but, if someone uses a + // service to change a property value and save (without setting name), the update date does not change. + } + } + + // refresh content + entity.SetCultureEdited(editedCultures!); + + // bump dates to align cultures to version + entity.AdjustDates(contentVersionDto.VersionDate, publishing); + + // replace the content version variations (rather than updating) + // only need to delete for the version that existed, the new version (if any) has no property data yet + Sql deleteContentVariations = Sql().Delete() + .Where(x => x.VersionId == versionToDelete); + Database.Execute(deleteContentVariations); + + // replace the element version variations (rather than updating) + Sql deleteElementVariations = Sql().Delete() + .Where(x => x.NodeId == entity.Id); + Database.Execute(deleteElementVariations); + + // TODO: NPoco InsertBulk issue? + // we should use the native NPoco InsertBulk here but it causes problems (not sure exactly all scenarios) + // but by using SQL Server and updating a variants name will cause: Unable to cast object of type + // 'Umbraco.Core.Persistence.FaultHandling.RetryDbConnection' to type 'System.Data.SqlClient.SqlConnection'. + // (same in PersistNewItem above) + + // insert content variations + Database.BulkInsertRecords(GetContentVariationDtos(entity, publishing)); + + // insert element variations + Database.BulkInsertRecords(GetElementVariationDtos(entity, editedCultures!)); + } + + // update the element dto + // at that point, when un/publishing, the entity still has its old Published value + // so we need to explicitly update the dto to persist the correct value + if (entity.PublishedState == PublishedState.Publishing) + { + dto.Published = true; + } + else if (entity.PublishedState == PublishedState.Unpublishing) + { + dto.Published = false; + } + + entity.Edited = dto.Edited = !dto.Published || edited; // if not published, always edited + Database.Update(dto); + + // if entity is publishing, update tags, else leave tags there + // means that implicitly unpublished, or trashed, entities *still* have tags in db + if (entity.PublishedState == PublishedState.Publishing) + { + SetEntityTags(entity, _tagRepository, _serializer); + } + } + + // trigger here, before we reset Published etc + // TODO ELEMENTS: implement + // OnUowRefreshedEntity(new ContentRefreshNotification(entity, new EventMessages())); + + if (!isMoving) + { + // flip the entity's published property + // this also flips its published state + if (entity.PublishedState == PublishedState.Publishing) + { + entity.Published = true; + entity.PublisherId = entity.WriterId; + entity.PublishName = entity.Name; + entity.PublishDate = entity.UpdateDate; + + SetEntityTags(entity, _tagRepository, _serializer); + } + else if (entity.PublishedState == PublishedState.Unpublishing) + { + entity.Published = false; + entity.PublisherId = null; + entity.PublishName = null; + entity.PublishDate = null; + + ClearEntityTags(entity, _tagRepository); + } + + PersistRelations(entity); + + // TODO: note re. tags: explicitly unpublished entities have cleared tags, but masked or trashed entities *still* have tags in the db - so what? + } + + entity.ResetDirtyProperties(); + + // We need to flush the isolated cache by key explicitly here. + // The ElementCacheRefresher does the same thing, but by the time it's invoked, custom notification handlers + // might have already consumed the cached version (which at this point is the previous version). + IsolatedCache.ClearByKey(RepositoryCacheKeys.GetKey(entity.Key)); + + // troubleshooting + //if (Database.ExecuteScalar($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.ElementVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.ElementVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE published=1 AND nodeId=" + content.Id) > 1) + //{ + // Debugger.Break(); + // throw new Exception("oops"); + //} + //if (Database.ExecuteScalar($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.ElementVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.ElementVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE [current]=1 AND nodeId=" + content.Id) > 1) + //{ + // Debugger.Break(); + // throw new Exception("oops"); + //} + } + + /// + public void PersistContentSchedule(IPublishableContentBase content, ContentScheduleCollection contentSchedule) + { + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (contentSchedule == null) + { + throw new ArgumentNullException(nameof(contentSchedule)); + } + + var schedules = ContentBaseFactory.BuildScheduleDto(content, contentSchedule, LanguageRepository).ToList(); + + //remove any that no longer exist + IEnumerable ids = schedules.Where(x => x.Model.Id != Guid.Empty).Select(x => x.Model.Id).Distinct(); + Database.Execute(Sql() + .Delete() + .Where(x => x.NodeId == content.Id) + .WhereNotIn(x => x.Id, ids)); + + //add/update the rest + foreach ((ContentSchedule Model, ContentScheduleDto Dto) schedule in schedules) + { + if (schedule.Model.Id == Guid.Empty) + { + schedule.Model.Id = schedule.Dto.Id = Guid.NewGuid(); + Database.Insert(schedule.Dto); + } + else + { + Database.Update(schedule.Dto); + } + } + } + + protected override void PersistDeletedItem(IElement entity) + { + // Raise event first else potential FK issues + OnUowRemovingEntity(entity); + + //now let the normal delete clauses take care of everything else + base.PersistDeletedItem(entity); + } + + #endregion + + #region Content Repository + + public int CountPublished(string? contentTypeAlias = null) + { + Sql sql = SqlContext.Sql(); + if (contentTypeAlias.IsNullOrWhiteSpace()) + { + sql.SelectCount() + .From() + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .Where(x => x.NodeObjectType == NodeObjectTypeId && x.Trashed == false) + .Where(x => x.Published); + } + else + { + sql.SelectCount() + .From() + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.NodeId, right => right.ContentTypeId) + .Where(x => x.NodeObjectType == NodeObjectTypeId && x.Trashed == false) + .Where(x => x.Alias == contentTypeAlias) + .Where(x => x.Published); + } + + return Database.ExecuteScalar(sql); + } + + /// + public override IEnumerable GetPage(IQuery? query, + long pageIndex, int pageSize, out long totalRecords, + IQuery? filter, Ordering? ordering) + { + Sql? filterSql = null; + + // if we have a filter, map its clauses to an Sql statement + if (filter != null) + { + // if the clause works on "name", we need to swap the field and use the variantName instead, + // so that querying also works on variant content (for instance when searching a listview). + + // figure out how the "name" field is going to look like - so we can look for it + var nameField = SqlContext.VisitModelField(x => x.Name); + + filterSql = Sql(); + foreach (Tuple filterClause in filter.GetWhereClauses()) + { + var clauseSql = filterClause.Item1; + var clauseArgs = filterClause.Item2; + + // replace the name field + // we cannot reference an aliased field in a WHERE clause, so have to repeat the expression here + clauseSql = clauseSql.Replace(nameField, VariantNameSqlExpression); + + // append the clause + filterSql.Append($"AND ({clauseSql})", clauseArgs); + } + } + + return GetPage(query, pageIndex, pageSize, out totalRecords, + x => MapDtosToContent(x), + filterSql, + ordering); + } + + // NOTE: Elements cannot have unpublished parents + public bool IsPathPublished(IElement? content) + => content is { Trashed: false, Published: true }; + + #endregion + + #region Recycle Bin + + public override int RecycleBinId => Constants.System.RecycleBinContent; + + public bool RecycleBinSmells() + { + IAppPolicyCache cache = _appCaches.RuntimeCache; + var cacheKey = CacheKeys.ContentRecycleBinCacheKey; + + // always cache either true or false + return cache.GetCacheItem(cacheKey, () => CountChildren(RecycleBinId) > 0); + } + + #endregion + + #region Read Repository implementation for Guid keys + + public IElement? Get(Guid id) => _elementByGuidReadRepository.Get(id); + + IEnumerable IReadRepository.GetMany(params Guid[]? ids) => + _elementByGuidReadRepository.GetMany(ids); + + public bool Exists(Guid id) => _elementByGuidReadRepository.Exists(id); + + // reading repository purely for looking up by GUID + // TODO: ugly and to fix we need to decouple the IRepositoryQueryable -> IRepository -> IReadRepository which should all be separate things! + // This sub-repository pattern is super old and totally unecessary anymore, caching can be handled in much nicer ways without this + private class ElementByGuidReadRepository : EntityRepositoryBase + { + private readonly ElementRepository _outerRepo; + + public ElementByGuidReadRepository(ElementRepository outerRepo, IScopeAccessor scopeAccessor, AppCaches cache, + ILogger logger) + : base(scopeAccessor, cache, logger) => + _outerRepo = outerRepo; + + protected override IElement? PerformGet(Guid id) + { + Sql sql = _outerRepo.GetBaseQuery(QueryType.Single) + .Where(x => x.UniqueId == id); + + ElementDto? dto = Database.Fetch(sql.SelectTop(1)).FirstOrDefault(); + + if (dto == null) + { + return null; + } + + IElement element = _outerRepo.MapDtoToContent(dto); + + return element; + } + + protected override IEnumerable PerformGetAll(params Guid[]? ids) + { + Sql sql = _outerRepo.GetBaseQuery(QueryType.Many); + if (ids?.Length > 0) + { + sql.WhereIn(x => x.UniqueId, ids); + } + + return _outerRepo.MapDtosToContent(Database.Fetch(sql)); + } + + protected override IEnumerable PerformGetByQuery(IQuery query) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override IEnumerable GetDeleteClauses() => + throw new InvalidOperationException("This method won't be implemented."); + + protected override void PersistNewItem(IElement entity) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override void PersistUpdatedItem(IElement entity) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override Sql GetBaseQuery(bool isCount) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override string GetBaseWhereClause() => + throw new InvalidOperationException("This method won't be implemented."); + } + + #endregion + + #region Schedule + + /// + public void ClearSchedule(DateTime date) + { + Sql sql = Sql().Delete().Where(x => x.Date <= date); + Database.Execute(sql); + } + + /// + public void ClearSchedule(DateTime date, ContentScheduleAction action) + { + var a = action.ToString(); + Sql sql = Sql().Delete() + .Where(x => x.Date <= date && x.Action == a); + Database.Execute(sql); + } + + private Sql GetSqlForHasScheduling(ContentScheduleAction action, DateTime date) + { + SqlTemplate template = SqlContext.Templates.Get("Umbraco.Core.ElementRepository.GetSqlForHasScheduling", + tsql => tsql + .SelectCount() + .From() + .Where(x => + x.Action == SqlTemplate.Arg("action") && x.Date <= SqlTemplate.Arg("date"))); + + Sql sql = template.Sql(action.ToString(), date); + return sql; + } + + public bool HasContentForExpiration(DateTime date) + { + Sql sql = GetSqlForHasScheduling(ContentScheduleAction.Expire, date); + return Database.ExecuteScalar(sql) > 0; + } + + public bool HasContentForRelease(DateTime date) + { + Sql sql = GetSqlForHasScheduling(ContentScheduleAction.Release, date); + return Database.ExecuteScalar(sql) > 0; + } + + /// + public IEnumerable GetContentForRelease(DateTime date) + { + var action = ContentScheduleAction.Release.ToString(); + + Sql sql = GetBaseQuery(QueryType.Many) + .WhereIn(x => x.NodeId, Sql() + .Select(x => x.NodeId) + .From() + .Where(x => x.Action == action && x.Date <= date)); + + AddGetByQueryOrderBy(sql); + + return MapDtosToContent(Database.Fetch(sql)); + } + + /// + public IDictionary> GetContentSchedulesByIds(int[] contentIds) + { + Sql sql = Sql() + .Select() + .From() + .WhereIn(contentScheduleDto => contentScheduleDto.NodeId, contentIds); + + List? contentScheduleDtos = Database.Fetch(sql); + + IDictionary> dictionary = contentScheduleDtos + .GroupBy(contentSchedule => contentSchedule.NodeId) + .ToDictionary( + group => group.Key, + group => group.Select(scheduleDto => new ContentSchedule( + scheduleDto.Id, + LanguageRepository.GetIsoCodeById(scheduleDto.LanguageId) ?? Constants.System.InvariantCulture, + scheduleDto.Date, + scheduleDto.Action == ContentScheduleAction.Release.ToString() + ? ContentScheduleAction.Release + : ContentScheduleAction.Expire)) + .ToList().AsEnumerable()); // We have to materialize it here, + // to avoid this being used after the scope is disposed. + + return dictionary; + } + + /// + public IEnumerable GetScheduledContentKeys(Guid[] keys) + { + var action = ContentScheduleAction.Release.ToString(); + DateTime now = DateTime.UtcNow; + + Sql sql = SqlContext.Sql(); + sql + .Select(x => x.UniqueId) + .From() + .InnerJoin().On(left => left.NodeId, right => right.NodeId) + .InnerJoin().On(left => left.NodeId, right => right.NodeId) + .WhereIn(x => x.UniqueId, keys) + .WhereIn(x => x.NodeId, Sql() + .Select(x => x.NodeId) + .From() + .Where(x => x.Action == action && x.Date >= now)); + + return Database.Fetch(sql); + } + + /// + public IEnumerable GetContentForExpiration(DateTime date) + { + var action = ContentScheduleAction.Expire.ToString(); + + Sql sql = GetBaseQuery(QueryType.Many) + .WhereIn(x => x.NodeId, Sql() + .Select(x => x.NodeId) + .From() + .Where(x => x.Action == action && x.Date <= date)); + + AddGetByQueryOrderBy(sql); + + return MapDtosToContent(Database.Fetch(sql)); + } + + #endregion + + #region Utilities + + private void SanitizeNames(IElement content, bool publishing) + { + // a content item *must* have an invariant name, and invariant published name + // else we just cannot write the invariant rows (node, content version...) to the database + + // ensure that we have an invariant name + // invariant content = must be there already, else throw + // variant content = update with default culture or anything really + EnsureInvariantNameExists(content); + + // ensure that invariant name is unique + EnsureInvariantNameIsUnique(content); + + // and finally, + // ensure that each culture has a unique node name + // no published name = not published + // else, it needs to be unique + EnsureVariantNamesAreUnique(content, publishing); + } + + private void EnsureInvariantNameExists(IElement content) + { + if (content.ContentType.VariesByCulture()) + { + // content varies by culture + // then it must have at least a variant name, else it makes no sense + if (content.CultureInfos?.Count == 0) + { + throw new InvalidOperationException("Cannot save content with an empty name."); + } + + // and then, we need to set the invariant name implicitly, + // using the default culture if it has a name, otherwise anything we can + var defaultCulture = LanguageRepository.GetDefaultIsoCode(); + content.Name = defaultCulture != null && + (content.CultureInfos?.TryGetValue(defaultCulture, out ContentCultureInfos cultureName) ?? + false) + ? cultureName.Name! + : content.CultureInfos![0].Name!; + } + else + { + // content is invariant, and invariant content must have an explicit invariant name + if (string.IsNullOrWhiteSpace(content.Name)) + { + throw new InvalidOperationException("Cannot save content with an empty name."); + } + } + } + + private void EnsureInvariantNameIsUnique(IElement content) => + content.Name = EnsureUniqueNodeName(content.ParentId, content.Name, content.Id); + + protected override string? EnsureUniqueNodeName(int parentId, string? nodeName, int id = 0) => + EnsureUniqueNaming == false ? nodeName : base.EnsureUniqueNodeName(parentId, nodeName, id); + + private SqlTemplate SqlEnsureVariantNamesAreUnique => SqlContext.Templates.Get( + "Umbraco.Core.DomainRepository.EnsureVariantNamesAreUnique", tsql => tsql + .Select(x => x.Id, x => x.Name, x => x.LanguageId) + .From() + .InnerJoin() + .On(x => x.Id, x => x.VersionId) + .InnerJoin().On(x => x.NodeId, x => x.NodeId) + .Where(x => x.Current == SqlTemplate.Arg("current")) + .Where(x => x.NodeObjectType == SqlTemplate.Arg("nodeObjectType") && + x.ParentId == SqlTemplate.Arg("parentId") && + x.NodeId != SqlTemplate.Arg("id")) + .OrderBy(x => x.LanguageId)); + + private void EnsureVariantNamesAreUnique(IElement content, bool publishing) + { + if (!EnsureUniqueNaming || !content.ContentType.VariesByCulture() || content.CultureInfos?.Count == 0) + { + return; + } + + // get names per culture, at same level (ie all siblings) + Sql sql = SqlEnsureVariantNamesAreUnique.Sql(true, NodeObjectTypeId, content.ParentId, content.Id); + var names = Database.Fetch(sql) + .GroupBy(x => x.LanguageId) + .ToDictionary(x => x.Key, x => x); + + if (names.Count == 0) + { + return; + } + + // note: the code below means we are going to unique-ify every culture names, regardless + // of whether the name has changed (ie the culture has been updated) - some saving culture + // fr-FR could cause culture en-UK name to change - not sure that is clean + + if (content.CultureInfos is null) + { + return; + } + + foreach (ContentCultureInfos cultureInfo in content.CultureInfos) + { + var langId = LanguageRepository.GetIdByIsoCode(cultureInfo.Culture); + if (!langId.HasValue) + { + continue; + } + + if (!names.TryGetValue(langId.Value, out IGrouping? cultureNames)) + { + continue; + } + + // get a unique name + IEnumerable otherNames = + cultureNames.Select(x => new SimilarNodeName {Id = x.Id, Name = x.Name}); + var uniqueName = SimilarNodeName.GetUniqueName(otherNames, 0, cultureInfo.Name); + + if (uniqueName == content.GetCultureName(cultureInfo.Culture)) + { + continue; + } + + // update the name, and the publish name if published + content.SetCultureName(uniqueName, cultureInfo.Culture); + if (publishing && (content.PublishCultureInfos?.ContainsKey(cultureInfo.Culture) ?? false)) + { + content.SetPublishInfo(cultureInfo.Culture, uniqueName, + DateTime.Now); //TODO: This is weird, this call will have already been made in the SetCultureName + } + } + } + + // ReSharper disable once ClassNeverInstantiated.Local + private class CultureNodeName + { + public int Id { get; set; } + public string? Name { get; set; } + public int LanguageId { get; set; } + } + + #endregion +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementVersionRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementVersionRepository.cs new file mode 100644 index 000000000000..ded4bfe4ccd7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementVersionRepository.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +internal sealed class ElementVersionRepository : ContentVersionRepositoryBase, IElementVersionRepository +{ + public ElementVersionRepository(IScopeAccessor scopeAccessor) + : base(scopeAccessor) + { + } + + protected override string ContentDtoTableName => ElementDto.TableName; + + protected override string ContentVersionDtoTableName => ElementVersionDto.TableName; +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs index 8d460ac56e93..0d962826c29d 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs @@ -37,6 +37,7 @@ public EntityContainerRepository( Constants.ObjectTypes.MediaTypeContainer, Constants.ObjectTypes.MemberTypeContainer, Constants.ObjectTypes.DocumentBlueprintContainer, + Constants.ObjectTypes.ElementContainer, }; NodeObjectTypeId = containerObjectType; if (allowedContainers.Contains(NodeObjectTypeId) == false) @@ -138,6 +139,7 @@ protected override Sql GetBaseQuery(bool isCount) containedObjectType, nodeDto.Text, nodeDto.UserId ?? Constants.Security.UnknownUserId); + entity.Trashed = nodeDto.Trashed; // reset dirty initial properties (U4-1946) entity.ResetDirtyProperties(false); @@ -324,11 +326,21 @@ protected override void PersistUpdatedItem(EntityContainer entity) // update nodeDto.Text = entity.Name; + nodeDto.Path = entity.Path; + nodeDto.Level = Convert.ToInt16(entity.Level); + nodeDto.Trashed = entity.Trashed; if (nodeDto.ParentId != entity.ParentId) { - nodeDto.Level = 0; - nodeDto.Path = "-1"; - if (entity.ParentId > -1) + nodeDto.Level = 1; + if (entity.ParentId == Constants.System.Root) + { + nodeDto.Path = $"{Constants.System.Root},{nodeDto.NodeId}"; + } + else if (entity.ParentId == Constants.System.RecycleBinElement) + { + nodeDto.Path = $"{Constants.System.RecycleBinElementPathPrefix}{nodeDto.NodeId}"; + } + else { NodeDto parent = Database.FirstOrDefault(Sql().SelectAll() .From() diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index a58d725e0628..be0b251fad31 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -104,11 +104,13 @@ public IEnumerable GetPagedResultsByQuery( objectType == Constants.ObjectTypes.Document || objectType == Constants.ObjectTypes.DocumentBlueprint); var isMedia = objectTypes.Any(objectType => objectType == Constants.ObjectTypes.Media); var isMember = objectTypes.Any(objectType => objectType == Constants.ObjectTypes.Member); + var isElement = objectTypes.Any(objectType => objectType == Constants.ObjectTypes.Element); Sql sql = GetBaseWhere( isContent, isMedia, isMember, + isElement, false, s => { @@ -128,7 +130,7 @@ public IEnumerable GetPagedResultsByQuery( var translator = new SqlTranslator(sql, query); sql = translator.Translate(); - sql = AddGroupBy(isContent, isMedia, isMember, sql, ordering.IsEmpty); + sql = AddGroupBy(isContent, isMedia, isMember, isElement, sql, ordering.IsEmpty); if (!ordering.IsEmpty) { @@ -147,19 +149,20 @@ public IEnumerable GetPagedResultsByQuery( EntitySlim[] entities = dtos.Select(BuildEntity).ToArray(); BuildVariants(entities.OfType()); + BuildVariants(entities.OfType()); return entities; } public IEntitySlim? Get(Guid key) { - Sql sql = GetBaseWhere(false, false, false, false, key); + Sql sql = GetBaseWhere(false, false, false, false, false, key); BaseDto? dto = Database.FirstOrDefault(sql); return dto == null ? null : BuildEntity(dto); } - private IEntitySlim? GetEntity(Sql sql, bool isContent, bool isMedia, bool isMember) + private IEntitySlim? GetEntity(Sql sql, bool isContent, bool isMedia, bool isMember, bool isElement) { // isContent is going to return a 1:M result now with the variants so we need to do different things if (isContent) @@ -169,6 +172,13 @@ public IEnumerable GetPagedResultsByQuery( return cdtos.Count == 0 ? null : BuildVariants(BuildDocumentEntity(cdtos[0])); } + if (isElement) + { + List? cdtos = Database.Fetch(sql); + + return cdtos.Count == 0 ? null : BuildVariants(BuildElementEntity(cdtos[0])); + } + BaseDto? dto = isMedia ? Database.FirstOrDefault(sql) : Database.FirstOrDefault(sql); @@ -367,14 +377,15 @@ private long GetNumberOfSiblingsOutsideSiblingRange( objectTypeId == Constants.ObjectTypes.DocumentBlueprint; var isMedia = objectTypeId == Constants.ObjectTypes.Media; var isMember = objectTypeId == Constants.ObjectTypes.Member; + var isElement = objectTypeId == Constants.ObjectTypes.Element; - Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectTypeId, key); - return GetEntity(sql, isContent, isMedia, isMember); + Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, objectTypeId, key); + return GetEntity(sql, isContent, isMedia, isMember, isElement); } public IEntitySlim? Get(int id) { - Sql sql = GetBaseWhere(false, false, false, false, id); + Sql sql = GetBaseWhere(false, false, false, false, false, id); BaseDto? dto = Database.FirstOrDefault(sql); return dto == null ? null : BuildEntity(dto); } @@ -385,9 +396,10 @@ private long GetNumberOfSiblingsOutsideSiblingRange( objectTypeId == Constants.ObjectTypes.DocumentBlueprint; var isMedia = objectTypeId == Constants.ObjectTypes.Media; var isMember = objectTypeId == Constants.ObjectTypes.Member; + var isElement = objectTypeId == Constants.ObjectTypes.Element; - Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectTypeId, id); - return GetEntity(sql, isContent, isMedia, isMember); + Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, objectTypeId, id); + return GetEntity(sql, isContent, isMedia, isMember, isElement); } public IEnumerable GetAll(Guid objectType, params int[] ids) => @@ -395,12 +407,28 @@ public IEnumerable GetAll(Guid objectType, params int[] ids) => ? PerformGetAll(objectType, sql => sql.WhereIn(x => x.NodeId, ids.Distinct())) : PerformGetAll(objectType); + public IEnumerable GetAll(IEnumerable objectTypes, params int[] ids) + { + Guid[] objectTypeArray = objectTypes.ToArray(); + return ids.Length > 0 + ? PerformGetAll(objectTypeArray, sql => sql.WhereIn(x => x.NodeId, ids.Distinct())) + : PerformGetAll(objectTypeArray); + } + public IEnumerable GetAll(Guid objectType, params Guid[] keys) => keys.Length > 0 ? PerformGetAll(objectType, sql => sql.WhereIn(x => x.UniqueId, keys.Distinct())) : PerformGetAll(objectType); - private IEnumerable GetEntities(Sql sql, bool isContent, bool isMedia, bool isMember) + public IEnumerable GetAll(IEnumerable objectTypes, params Guid[] keys) + { + Guid[] objectTypeArray = objectTypes.ToArray(); + return keys.Length > 0 + ? PerformGetAll(objectTypeArray, sql => sql.WhereIn(x => x.UniqueId, keys.Distinct())) + : PerformGetAll(objectTypeArray); + } + + private IEnumerable GetEntities(Sql sql, bool isContent, bool isMedia, bool isMember, bool isElement) { // isContent is going to return a 1:M result now with the variants so we need to do different things if (isContent) @@ -412,6 +440,15 @@ private IEnumerable GetEntities(Sql sql, bool isConten : BuildVariants(cdtos.Select(BuildDocumentEntity)).ToList(); } + if (isElement) + { + List? cdtos = Database.Fetch(sql); + + return cdtos.Count == 0 + ? Enumerable.Empty() + : BuildVariants(cdtos.Select(BuildElementEntity)).ToList(); + } + IEnumerable? dtos = isMedia ? (IEnumerable)Database.Fetch(sql) : Database.Fetch(sql); @@ -427,9 +464,22 @@ private IEnumerable PerformGetAll(Guid objectType, Action sql = GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, objectType, filter); + return GetEntities(sql, isContent, isMedia, isMember, isElement); + } + + private IEnumerable PerformGetAll(Guid[] objectTypes, Action>? filter = null) + { + var isContent = objectTypes.Contains(Constants.ObjectTypes.Document) || + objectTypes.Contains(Constants.ObjectTypes.DocumentBlueprint); + var isMedia = objectTypes.Contains(Constants.ObjectTypes.Media); + var isMember = objectTypes.Contains(Constants.ObjectTypes.Member); + var isElement = objectTypes.Contains(Constants.ObjectTypes.Element); - Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectType, filter); - return GetEntities(sql, isContent, isMedia, isMember); + Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, objectTypes, filter); + return GetEntities(sql, isContent, isMedia, isMember, isElement); } private IEnumerable PerformGetAll( @@ -441,9 +491,10 @@ private IEnumerable PerformGetAll( objectTypes.Contains(Constants.ObjectTypes.DocumentBlueprint); var isMedia = objectTypes.Contains(Constants.ObjectTypes.Media); var isMember = objectTypes.Contains(Constants.ObjectTypes.Member); + var isElement = objectTypes.Contains(Constants.ObjectTypes.Element); - Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectTypes, ordering, filter); - return GetEntities(sql, isContent, isMedia, isMember); + Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, objectTypes, ordering, filter); + return GetEntities(sql, isContent, isMedia, isMember, isElement); } public IEnumerable GetAllPaths(Guid objectType, params int[]? ids) => @@ -470,10 +521,10 @@ private IEnumerable PerformGetAllPaths(Guid objectType, Action GetByQuery(IQuery query) { - Sql sqlClause = GetBase(false, false, false, null); + Sql sqlClause = GetBase(false, false, false, false, null); var translator = new SqlTranslator(sqlClause, query); Sql sql = translator.Translate(); - sql = AddGroupBy(false, false, false, sql, true); + sql = AddGroupBy(false, false, false, false, sql, true); List? dtos = Database.Fetch(sql); return dtos.Select(BuildEntity).ToList(); } @@ -484,14 +535,15 @@ public IEnumerable GetByQuery(IQuery query, Guid ob objectType == Constants.ObjectTypes.DocumentBlueprint; var isMedia = objectType == Constants.ObjectTypes.Media; var isMember = objectType == Constants.ObjectTypes.Member; + var isElement = objectType == Constants.ObjectTypes.Element; - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, null, new[] { objectType }); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, null, new[] { objectType }); var translator = new SqlTranslator(sql, query); sql = translator.Translate(); - sql = AddGroupBy(isContent, isMedia, isMember, sql, true); + sql = AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); - return GetEntities(sql, isContent, isMedia, isMember); + return GetEntities(sql, isContent, isMedia, isMember, isElement); } public UmbracoObjectTypes GetObjectType(int id) @@ -581,18 +633,20 @@ public bool Exists(int id) return Database.ExecuteScalar(sql) > 0; } - private DocumentEntitySlim BuildVariants(DocumentEntitySlim entity) - => BuildVariants(new[] { entity }).First(); + private TEntity BuildVariants(TEntity entity) + where TEntity : PublishableContentEntitySlim + => BuildVariants([entity]).First(); - private IEnumerable BuildVariants(IEnumerable entities) + private IEnumerable BuildVariants(IEnumerable entities) + where TEntity : PublishableContentEntitySlim { - List? v = null; + List? v = null; var entitiesList = entities.ToList(); - foreach (DocumentEntitySlim e in entitiesList) + foreach (TEntity e in entitiesList) { if (e.Variations.VariesByCulture()) { - (v ??= new List()).Add(e); + (v ??= new List()).Add(e); } } @@ -601,16 +655,22 @@ private IEnumerable BuildVariants(IEnumerable, Sql> getVariantInfos = typeof(TEntity) == typeof(DocumentEntitySlim) + ? GetDocumentVariantInfos + : typeof(TEntity) == typeof(ElementEntitySlim) + ? GetElementVariantInfos + : throw new NotSupportedException($"The supplied entity type is not supported: {typeof(TEntity).FullName}"); + // fetch all variant info dtos IEnumerable dtos = Database.FetchByGroups( v.Select(x => x.Id), Constants.Sql.MaxParameterCount, - GetVariantInfos); + getVariantInfos); // group by node id (each group contains all languages) var xdtos = dtos.GroupBy(x => x.NodeId).ToDictionary(x => x.Key, x => x); - foreach (DocumentEntitySlim e in v) + foreach (TEntity e in v) { // since we're only iterating on entities that vary, we must have something IGrouping edtos = xdtos[e.Id]; @@ -627,7 +687,7 @@ private IEnumerable BuildVariants(IEnumerable GetVariantInfos(IEnumerable ids) => + private Sql GetDocumentVariantInfos(IEnumerable ids) => Sql() .Select(x => x.NodeId) .AndSelect(x => x.IsoCode) @@ -635,14 +695,14 @@ private Sql GetVariantInfos(IEnumerable ids) => "doc", x => Alias( x.Published, - "DocumentPublished"), - x => Alias(x.Edited, "DocumentEdited")) + nameof(VariantInfoDto.DocumentPublished)), + x => Alias(x.Edited, nameof(VariantInfoDto.DocumentEdited))) .AndSelect( "dcv", - x => Alias(x.Available, "CultureAvailable"), - x => Alias(x.Published, "CulturePublished"), - x => Alias(x.Edited, "CultureEdited"), - x => Alias(x.Name, "Name")) + x => Alias(x.Available, nameof(VariantInfoDto.CultureAvailable)), + x => Alias(x.Published, nameof(VariantInfoDto.CulturePublished)), + x => Alias(x.Edited, nameof(VariantInfoDto.CultureEdited)), + x => Alias(x.Name, nameof(VariantInfoDto.Name))) // from node x language .From() @@ -661,16 +721,52 @@ private Sql GetVariantInfos(IEnumerable ids) => .WhereIn(x => x.NodeId, ids) .OrderBy(x => x.Id); + // TODO ELEMENTS: this is a copy/paste of GetDocumentVariantInfos, adjusted for elements - can we refactor in any way? + private Sql GetElementVariantInfos(IEnumerable ids) => + Sql() + .Select(x => x.NodeId) + .AndSelect(x => x.IsoCode) + .AndSelect( + "doc", + x => Alias( + x.Published, + nameof(VariantInfoDto.DocumentPublished)), + x => Alias(x.Edited, nameof(VariantInfoDto.DocumentEdited))) + .AndSelect( + "dcv", + x => Alias(x.Available, nameof(VariantInfoDto.CultureAvailable)), + x => Alias(x.Published, nameof(VariantInfoDto.CulturePublished)), + x => Alias(x.Edited, nameof(VariantInfoDto.CultureEdited)), + x => Alias(x.Name, nameof(VariantInfoDto.Name))) + + // from node x language + .From() + .CrossJoin() + + // join to element - always exists - indicates global element published/edited status + .InnerJoin("doc") + .On((node, doc) => node.NodeId == doc.NodeId, aliasRight: "doc") + + // left-join do element variation - matches cultures that are *available* + indicates when *edited* + .LeftJoin("dcv") + .On( + (node, dcv, lang) => node.NodeId == dcv.NodeId && lang.Id == dcv.LanguageId, aliasRight: "dcv") + + // for selected nodes + .WhereIn(x => x.NodeId, ids) + .OrderBy(x => x.Id); + // gets the full sql for a given object type and a given unique id private Sql GetFullSqlForEntityType( bool isContent, bool isMedia, bool isMember, + bool isElement, Guid objectType, Guid uniqueId) { - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, objectType, uniqueId); - return AddGroupBy(isContent, isMedia, isMember, sql, true); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, objectType, uniqueId); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); } // gets the full sql for a given object type and a given node id @@ -678,11 +774,12 @@ private Sql GetFullSqlForEntityType( bool isContent, bool isMedia, bool isMember, + bool isElement, Guid objectType, int nodeId) { - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, objectType, nodeId); - return AddGroupBy(isContent, isMedia, isMember, sql, true); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, objectType, nodeId); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); } // gets the full sql for a given object type, with a given filter @@ -690,43 +787,59 @@ private Sql GetFullSqlForEntityType( bool isContent, bool isMedia, bool isMember, + bool isElement, Guid objectType, Action>? filter) { - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, filter, new[] { objectType }); - return AddGroupBy(isContent, isMedia, isMember, sql, true); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, filter, [objectType]); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); } + // gets the full sql for multiple object types, with a given filter private Sql GetFullSqlForEntityType( bool isContent, bool isMedia, bool isMember, + bool isElement, + Guid[] objectTypes, + Action>? filter) + { + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, filter, objectTypes); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); + } + + private Sql GetFullSqlForEntityType( + bool isContent, + bool isMedia, + bool isMember, + bool isElement, Guid objectType, Ordering ordering, Action>? filter) - => GetFullSqlForEntityType(isContent, isMedia, isMember, [objectType], ordering, filter); + => GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, [objectType], ordering, filter); private Sql GetFullSqlForEntityType( bool isContent, bool isMedia, bool isMember, + bool isElement, Guid[] objectTypes, Ordering ordering, Action>? filter) { - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, filter, objectTypes); - AddGroupBy(isContent, isMedia, isMember, sql, false); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, filter, objectTypes); + AddGroupBy(isContent, isMedia, isMember, isElement, sql, false); ApplyOrdering(ref sql, ordering); return sql; } - private Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, bool isCount = false) - => GetBase(isContent, isMedia, isMember, filter, [], isCount); + private Sql GetBase(bool isContent, bool isMedia, bool isMember, bool isElement, Action>? filter, bool isCount = false) + => GetBase(isContent, isMedia, isMember, isElement, filter, [], isCount); // gets the base SELECT + FROM [+ filter] sql // always from the 'current' content version - private Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, Guid[] objectTypes, bool isCount = false) + private Sql GetBase(bool isContent, bool isMedia, bool isMember, bool isElement, Action>? filter, Guid[] objectTypes, bool isCount = false) { Sql sql = Sql(); ISqlSyntaxProvider syntax = SqlContext.SqlSyntax; @@ -763,7 +876,7 @@ private Sql GetBase(bool isContent, bool isMedia, bool isMember, Ac sql.Append($", SUM(CASE WHEN child.{syntax.GetQuotedColumnName("nodeObjectType")} IN ('{objectTypesForInClause}') THEN 1 ELSE 0 END) AS children"); } - if (isContent || isMedia || isMember) + if (isContent || isMedia || isMember || isElement) { sql .AndSelect(x => Alias(x.Id, "versionId"), x => x.VersionDate) @@ -782,6 +895,12 @@ private Sql GetBase(bool isContent, bool isMedia, bool isMember, Ac .AndSelect(x => x.Published, x => x.Edited); } + if (isElement) + { + sql + .AndSelect(x => x.Published, x => x.Edited); + } + if (isMedia) { sql @@ -792,7 +911,7 @@ private Sql GetBase(bool isContent, bool isMedia, bool isMember, Ac sql .From(); - if (isContent || isMedia || isMember) + if (isContent || isMedia || isMember || isElement) { sql .LeftJoin() @@ -811,6 +930,12 @@ private Sql GetBase(bool isContent, bool isMedia, bool isMember, Ac .LeftJoin().On((left, right) => left.NodeId == right.NodeId); } + if (isElement) + { + sql + .LeftJoin().On((left, right) => left.NodeId == right.NodeId); + } + if (isMedia) { sql @@ -838,11 +963,12 @@ private Sql GetBaseWhere( bool isContent, bool isMedia, bool isMember, + bool isElement, bool isCount, Action>? filter, Guid[] objectTypes) { - Sql sql = GetBase(isContent, isMedia, isMember, filter, objectTypes, isCount); + Sql sql = GetBase(isContent, isMedia, isMember, isElement, filter, objectTypes, isCount); if (objectTypes.Length > 0) { sql.WhereIn(x => x.NodeObjectType, objectTypes); @@ -853,20 +979,20 @@ private Sql GetBaseWhere( // gets the base SELECT + FROM + WHERE sql // for a given node id - private Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, int id) + private Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isElement, bool isCount, int id) { - Sql sql = GetBase(isContent, isMedia, isMember, null, isCount) + Sql sql = GetBase(isContent, isMedia, isMember, isElement, null, isCount) .Where(x => x.NodeId == id); - return AddGroupBy(isContent, isMedia, isMember, sql, true); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); } // gets the base SELECT + FROM + WHERE sql // for a given unique id - private Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, Guid uniqueId) + private Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isElement, bool isCount, Guid uniqueId) { - Sql sql = GetBase(isContent, isMedia, isMember, null, isCount) + Sql sql = GetBase(isContent, isMedia, isMember, isElement, null, isCount) .Where(x => x.UniqueId == uniqueId); - return AddGroupBy(isContent, isMedia, isMember, sql, true); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); } // gets the base SELECT + FROM + WHERE sql @@ -875,10 +1001,11 @@ private Sql GetBaseWhere( bool isContent, bool isMedia, bool isMember, + bool isElement, bool isCount, Guid objectType, int nodeId) => - GetBase(isContent, isMedia, isMember, null, isCount) + GetBase(isContent, isMedia, isMember, isElement, null, isCount) .Where(x => x.NodeId == nodeId && x.NodeObjectType == objectType); // gets the base SELECT + FROM + WHERE sql @@ -887,10 +1014,11 @@ private Sql GetBaseWhere( bool isContent, bool isMedia, bool isMember, + bool isElement, bool isCount, Guid objectType, Guid uniqueId) => - GetBase(isContent, isMedia, isMember, null, isCount) + GetBase(isContent, isMedia, isMember, isElement, null, isCount) .Where(x => x.UniqueId == uniqueId && x.NodeObjectType == objectType); // gets the GROUP BY / ORDER BY sql @@ -899,6 +1027,7 @@ private Sql AddGroupBy( bool isContent, bool isMedia, bool isMember, + bool isElement, Sql sql, bool defaultSort) { @@ -912,6 +1041,12 @@ private Sql AddGroupBy( .AndBy(x => x.Published, x => x.Edited); } + if (isElement) + { + sql + .AndBy(x => x.Published, x => x.Edited); + } + if (isMedia) { sql @@ -919,7 +1054,7 @@ private Sql AddGroupBy( } - if (isContent || isMedia || isMember) + if (isContent || isMedia || isMember || isElement) { sql .AndBy(x => x.Id, x => x.VersionDate) @@ -1017,7 +1152,7 @@ private void ApplyOrdering(ref Sql sql, Ordering ordering) /// /// The DTO used to fetch results for a generic content item which could be either a document, media or a member /// - private sealed class GenericContentEntityDto : DocumentEntityDto + private sealed class GenericContentEntityDto : PublishableEntityDto { public string? MediaPath { get; set; } } @@ -1025,12 +1160,15 @@ private sealed class GenericContentEntityDto : DocumentEntityDto /// /// The DTO used to fetch results for a document item with its variation info /// - private class DocumentEntityDto : BaseDto + private class DocumentEntityDto : PublishableEntityDto { - public ContentVariation Variations { get; set; } + } - public bool Published { get; set; } - public bool Edited { get; set; } + /// + /// The DTO used to fetch results for an element item with its variation info + /// + private class ElementEntityDto : PublishableEntityDto + { } /// @@ -1048,7 +1186,16 @@ private sealed class MemberEntityDto : BaseDto { } - public class VariantInfoDto + private abstract class PublishableEntityDto : BaseDto + { + public ContentVariation Variations { get; set; } + + public bool Published { get; set; } + + public bool Edited { get; set; } + } + + private class VariantInfoDto { public int NodeId { get; set; } public string IsoCode { get; set; } = null!; @@ -1118,6 +1265,11 @@ private EntitySlim BuildEntity(BaseDto dto) return BuildMemberEntity(dto); } + if (dto.NodeObjectType == Constants.ObjectTypes.Element) + { + return BuildElementEntity(dto); + } + // EntitySlim does not track changes var entity = new EntitySlim(); BuildEntity(entity, dto); @@ -1177,7 +1329,24 @@ private static DocumentEntitySlim BuildDocumentEntity(BaseDto dto) var entity = new DocumentEntitySlim(); BuildContentEntity(entity, dto); - if (dto is DocumentEntityDto contentDto) + if (dto is PublishableEntityDto contentDto) + { + // fill in the invariant info + entity.Edited = contentDto.Edited; + entity.Published = contentDto.Published; + entity.Variations = contentDto.Variations; + } + + return entity; + } + + private static ElementEntitySlim BuildElementEntity(BaseDto dto) + { + // EntitySlim does not track changes + var entity = new ElementEntitySlim(); + BuildContentEntity(entity, dto); + + if (dto is PublishableEntityDto contentDto) { // fill in the invariant info entity.Edited = contentDto.Edited; diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs index 2a5e31d50f2c..969d20df251f 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs @@ -478,6 +478,7 @@ private static void AppendGroupBy(Sql sql) => x => x.Id, x => x.StartContentId, x => x.StartMediaId, + x => x.StartElementId, x => x.UpdateDate, x => x.Alias, x => x.Name, diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs index 1de89e8a09fe..ae766f7dbd94 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs @@ -741,6 +741,15 @@ protected override void PersistNewItem(IUser entity) entity.StartMediaIds); } + if (entity.IsPropertyDirty("StartElementIds")) + { + AddingOrUpdateStartNodes( + entity, + Enumerable.Empty(), + UserStartNodeDto.StartNodeTypeValue.Element, + entity.StartElementIds); + } + if (entity.IsPropertyDirty("Groups")) { // Lookup all assigned groups. @@ -859,7 +868,7 @@ protected override void PersistUpdatedItem(IUser entity) Database.Update(userDto, changedCols); } - if (entity.IsPropertyDirty("StartContentIds") || entity.IsPropertyDirty("StartMediaIds")) + if (entity.IsPropertyDirty("StartContentIds") || entity.IsPropertyDirty("StartMediaIds") || entity.IsPropertyDirty("StartElementIds")) { Sql sql = SqlContext.Sql() .SelectAll() @@ -877,6 +886,11 @@ protected override void PersistUpdatedItem(IUser entity) { AddingOrUpdateStartNodes(entity, assignedStartNodes, UserStartNodeDto.StartNodeTypeValue.Media, entity.StartMediaIds); } + + if (entity.IsPropertyDirty("StartElementIds")) + { + AddingOrUpdateStartNodes(entity, assignedStartNodes, UserStartNodeDto.StartNodeTypeValue.Element, entity.StartElementIds); + } } if (entity.IsPropertyDirty("Groups")) diff --git a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs index e97c60fd6253..2fc99b29e19d 100644 --- a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs @@ -45,6 +45,7 @@ public static IUmbracoBuilder AddUmbracoHybridCache(this IUmbracoBuilder builder builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs index e95c855093c5..9ecee3bfbf04 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs @@ -37,7 +37,7 @@ public ContentCacheNode ToContentCacheNode(IContent content, bool preview) }; } - private static bool GetPublishedValue(IContent content, bool preview) + private static bool GetPublishedValue(IPublishableContentBase content, bool preview) { switch (content.PublishedState) { @@ -84,6 +84,26 @@ public ContentCacheNode ToContentCacheNode(IMedia media) }; } + public ContentCacheNode ToContentCacheNode(IElement element, bool preview) + { + ContentData contentData = GetContentData( + element, + GetPublishedValue(element, preview), + null, + element.PublishCultureInfos?.Values.Select(x => x.Culture).ToHashSet() ?? []); + return new ContentCacheNode + { + Id = element.Id, + Key = element.Key, + SortOrder = element.SortOrder, + CreateDate = element.CreateDate, + CreatorId = element.CreatorId, + ContentTypeId = element.ContentTypeId, + Data = contentData, + IsDraft = false, + }; + } + private ContentData GetContentData(IContentBase content, bool published, int? templateId, ISet publishedCultures) { var propertyData = new Dictionary(); @@ -128,10 +148,11 @@ private ContentData GetContentData(IContentBase content, bool published, int? te // sanitize - names should be ok but ... never knows if (content.ContentType.VariesByCulture()) { - ContentCultureInfosCollection? infos = content is IContent document + var publishableContent = content as IPublishableContentBase; + ContentCultureInfosCollection? infos = publishableContent is not null ? published - ? document.PublishCultureInfos - : document.CultureInfos + ? publishableContent.PublishCultureInfos + : publishableContent.CultureInfos : content.CultureInfos; // ReSharper disable once UseDeconstruction @@ -139,7 +160,7 @@ private ContentData GetContentData(IContentBase content, bool published, int? te { foreach (ContentCultureInfos cultureInfo in infos) { - var cultureIsDraft = !published && content is IContent d && d.IsCultureEdited(cultureInfo.Culture); + var cultureIsDraft = !published && publishableContent is not null && publishableContent.IsCultureEdited(cultureInfo.Culture); cultureData[cultureInfo.Culture] = new CultureVariation { Name = cultureInfo.Name, diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs index f16ea2b16260..8a55e4867240 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs @@ -6,4 +6,6 @@ internal interface ICacheNodeFactory { ContentCacheNode ToContentCacheNode(IContent content, bool preview); ContentCacheNode ToContentCacheNode(IMedia media); + + ContentCacheNode ToContentCacheNode(IElement element, bool preview); } diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs index 46cb65d5e4b9..47e0ae706222 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs @@ -22,4 +22,6 @@ internal interface IPublishedContentFactory /// Converts a to an . /// IPublishedMember ToPublishedMember(IMember member); + + IPublishedElement? ToIPublishedElement(ContentCacheNode contentCacheNode, bool preview); } diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs index a6c6273645e2..22f9308b64f2 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs @@ -30,17 +30,7 @@ public PublishedContentFactory( /// public IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview) { - IPublishedContentType contentType = - _publishedContentTypeCache.Get(PublishedItemType.Content, contentCacheNode.ContentTypeId); - var contentNode = new ContentNode( - contentCacheNode.Id, - contentCacheNode.Key, - contentCacheNode.SortOrder, - contentCacheNode.CreateDate.EnsureUtc(), - contentCacheNode.CreatorId, - contentType, - preview ? contentCacheNode.Data : null, - preview ? null : contentCacheNode.Data); + ContentNode contentNode = CreateContentNode(contentCacheNode, preview); IPublishedContent? publishedContent = GetModel(contentNode, preview); @@ -52,6 +42,21 @@ public PublishedContentFactory( return publishedContent; } + public IPublishedElement? ToIPublishedElement(ContentCacheNode contentCacheNode, bool preview) + { + ContentNode contentNode = CreateContentNode(contentCacheNode, preview); + + IPublishedElement? publishedElement = GetPublishedElement(contentNode, preview); + + if (preview) + { + // TODO ELEMENTS: what is the element equivalent of this? + // return model ?? GetPublishedContentAsDraft(model); + } + + return publishedElement; + } + /// public IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode) { @@ -100,6 +105,20 @@ public IPublishedMember ToPublishedMember(IMember member) return new PublishedMember(member, contentNode, _elementsCache, _variationContextAccessor); } + private ContentNode CreateContentNode(ContentCacheNode contentCacheNode, bool preview) + { + IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Content, contentCacheNode.ContentTypeId); + return new ContentNode( + contentCacheNode.Id, + contentCacheNode.Key, + contentCacheNode.SortOrder, + contentCacheNode.CreateDate, + contentCacheNode.CreatorId, + contentType, + preview ? contentCacheNode.Data : null, + preview ? null : contentCacheNode.Data); + } + private static Dictionary GetPropertyValues(IPublishedContentType contentType, IMember member) { var properties = member @@ -134,6 +153,7 @@ private static void AddIf(IPublishedContentType contentType, IDictionary content == null ? null : // an object in the cache is either an IPublishedContentOrMedia, diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs index 0d0f20b124aa..c687f867aa07 100644 --- a/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs @@ -2,83 +2,28 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; -using Umbraco.Cms.Core.PublishedCache; using Umbraco.Extensions; using Umbraco.Cms.Core.Extensions; namespace Umbraco.Cms.Infrastructure.HybridCache; -internal class PublishedContent : PublishedContentBase +internal class PublishedContent : PublishedElement, IPublishedContent { - private IPublishedProperty[] _properties; - private readonly ContentNode _contentNode; - private IReadOnlyDictionary? _cultures; - private readonly string? _urlSegment; - private readonly IReadOnlyDictionary? _cultureInfos; - private readonly string _contentName; - private readonly bool _published; - public PublishedContent( ContentNode contentNode, bool preview, IElementsCache elementsCache, IVariationContextAccessor variationContextAccessor) - : base(variationContextAccessor) + : base(contentNode, preview, elementsCache, variationContextAccessor) { - VariationContextAccessor = variationContextAccessor; - _contentNode = contentNode; - ContentData? contentData = preview ? _contentNode.DraftModel : _contentNode.PublishedModel; - if (contentData is null) - { - throw new ArgumentNullException(nameof(contentData)); - } - - _cultureInfos = contentData.CultureInfos; - _contentName = contentData.Name; - _urlSegment = contentData.UrlSegment; - _published = contentData.Published; - - IsPreviewing = preview; - - var properties = new IPublishedProperty[_contentNode.ContentType.PropertyTypes.Count()]; - var i = 0; - foreach (IPublishedPropertyType propertyType in _contentNode.ContentType.PropertyTypes) - { - // add one property per property type - this is required, for the indexing to work - // if contentData supplies pdatas, use them, else use null - contentData.Properties.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas); // else will be null - properties[i++] = new PublishedProperty(propertyType, this, propertyDatas, elementsCache, propertyType.CacheLevel); - } - - _properties = properties; - - Id = contentNode.Id; - Key = contentNode.Key; - CreatorId = contentNode.CreatorId; - CreateDate = contentNode.CreateDate.EnsureUtc(); - SortOrder = contentNode.SortOrder; - WriterId = contentData.WriterId; - TemplateId = contentData.TemplateId; - UpdateDate = contentData.VersionDate.EnsureUtc(); } - public override IPublishedContentType ContentType => _contentNode.ContentType; - - public override Guid Key { get; } - - public override IEnumerable Properties => _properties; - - public override int Id { get; } - - public override int SortOrder { get; } - [Obsolete] - public override string Path + public string Path { get { @@ -120,28 +65,15 @@ private UmbracoObjectTypes GetObjectType() } } - public override int? TemplateId { get; } - - public override int CreatorId { get; } - - public override DateTime CreateDate { get; } - - public override int WriterId { get; } - - public override DateTime UpdateDate { get; } - - public bool IsPreviewing { get; } - - // Needed for publishedProperty - internal IVariationContextAccessor VariationContextAccessor { get; } + public int? TemplateId => ContentData.TemplateId; [Obsolete("Use the INavigationQueryService instead, scheduled for removal in v17")] - public override int Level + public int Level { get { INavigationQueryService? navigationQueryService; - switch (_contentNode.ContentType.ItemType) + switch (ContentNode.ContentType.ItemType) { case PublishedItemType.Content: navigationQueryService = StaticServiceProvider.Instance.GetRequiredService(); @@ -150,7 +82,7 @@ public override int Level navigationQueryService = StaticServiceProvider.Instance.GetRequiredService(); break; default: - throw new NotImplementedException("Level is not implemented for " + _contentNode.ContentType.ItemType); + throw new NotImplementedException("Level is not implemented for " + ContentNode.ContentType.ItemType); } // Attempt to retrieve the level, returning 0 if it fails or if level is null. @@ -164,111 +96,39 @@ public override int Level } [Obsolete("Please use TryGetParentKey() on IDocumentNavigationQueryService or IMediaNavigationQueryService instead. Scheduled for removal in V16.")] - public override IPublishedContent? Parent => GetParent(); + public IPublishedContent? Parent => GetParent(); /// - public override IReadOnlyDictionary Cultures - { - get - { - if (_cultures != null) - { - return _cultures; - } - - if (!ContentType.VariesByCulture()) - { - return _cultures = new Dictionary - { - { string.Empty, new PublishedCultureInfo(string.Empty, _contentName, _urlSegment, CreateDate) }, - }; - } - - if (_cultureInfos == null) - { - throw new PanicException("_contentDate.CultureInfos is null."); - } - - - return _cultures = _cultureInfos - .ToDictionary( - x => x.Key, - x => new PublishedCultureInfo(x.Key, x.Value.Name, x.Value.UrlSegment, x.Value.Date), - StringComparer.OrdinalIgnoreCase); - } - } - - /// - public override PublishedItemType ItemType => _contentNode.ContentType.ItemType; - - public override IPublishedProperty? GetProperty(string alias) - { - var index = _contentNode.ContentType.GetPropertyIndex(alias); - if (index < 0) - { - return null; // happens when 'alias' does not match a content type property alias - } - - // should never happen - properties array must be in sync with property type - if (index >= _properties.Length) - { - throw new IndexOutOfRangeException( - "Index points outside the properties array, which means the properties array is corrupt."); - } - - IPublishedProperty property = _properties[index]; - return property; - } - - public override bool IsDraft(string? culture = null) - { - // if this is the 'published' published content, nothing can be draft - if (_published) - { - return false; - } - - // not the 'published' published content, and does not vary = must be draft - if (!ContentType.VariesByCulture()) - { - return true; - } + [Obsolete("Please use TryGetChildrenKeys() on IDocumentNavigationQueryService or IMediaNavigationQueryService instead. Scheduled for removal in V16.")] + public virtual IEnumerable Children => GetChildren(); - // handle context culture - culture ??= VariationContextAccessor?.VariationContext?.Culture ?? string.Empty; - - // not the 'published' published content, and varies - // = depends on the culture - return _cultureInfos is not null && _cultureInfos.TryGetValue(culture, out CultureVariation? cvar) && cvar.IsDraft; - } + /// + [Obsolete("Please use GetUrlSegment() on IDocumentUrlService instead. Scheduled for removal in V16.")] + public virtual string? UrlSegment => this.UrlSegment(VariationContextAccessor); - public override bool IsPublished(string? culture = null) + private IPublishedContent? GetParent() { - // whether we are the 'draft' or 'published' content, need to determine whether - // there is a 'published' version for the specified culture (or at all, for - // invariant content items) - - // if there is no 'published' published content, no culture can be published - if (!_contentNode.HasPublished) - { - return false; - } + INavigationQueryService? navigationQueryService; + IPublishedStatusFilteringService? publishedStatusFilteringService; - // if there is a 'published' published content, and does not vary = published - if (!ContentType.VariesByCulture()) + switch (ContentType.ItemType) { - return true; + case PublishedItemType.Content: + navigationQueryService = StaticServiceProvider.Instance.GetRequiredService(); + publishedStatusFilteringService = StaticServiceProvider.Instance.GetRequiredService(); + break; + case PublishedItemType.Media: + navigationQueryService = StaticServiceProvider.Instance.GetRequiredService(); + publishedStatusFilteringService = StaticServiceProvider.Instance.GetRequiredService(); + break; + default: + throw new NotImplementedException("Level is not implemented for " + ContentType.ItemType); } - // handle context culture - culture ??= VariationContextAccessor.VariationContext?.Culture ?? string.Empty; - - // there is a 'published' published content, and varies - // = depends on the culture - return _contentNode.HasPublishedCulture(culture); + return this.Parent(navigationQueryService, publishedStatusFilteringService); } - private IPublishedContent? GetParent() + private IEnumerable GetChildren() { INavigationQueryService? navigationQueryService; IPublishedStatusFilteringService? publishedStatusFilteringService; @@ -287,6 +147,6 @@ public override bool IsPublished(string? culture = null) throw new NotImplementedException("Level is not implemented for " + ContentType.ItemType); } - return this.Parent(navigationQueryService, publishedStatusFilteringService); + return this.Children(navigationQueryService, publishedStatusFilteringService); } } diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedElement.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedElement.cs new file mode 100644 index 000000000000..07991795484e --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedElement.cs @@ -0,0 +1,186 @@ +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +internal class PublishedElement : PublishableContentBase, IPublishedElement +{ + private IPublishedProperty[] _properties; + private IReadOnlyDictionary? _cultures; + private readonly string? _urlSegment; + private readonly IReadOnlyDictionary? _cultureInfos; + private readonly string _contentName; + private readonly bool _published; + + public PublishedElement( + ContentNode contentNode, + bool preview, + IElementsCache elementsCache, + IVariationContextAccessor variationContextAccessor) + { + VariationContextAccessor = variationContextAccessor; + ContentNode = contentNode; + ContentData? contentData = preview ? ContentNode.DraftModel : ContentNode.PublishedModel; + if (contentData is null) + { + throw new ArgumentNullException(nameof(contentData)); + } + ContentData = contentData; + + _cultureInfos = contentData.CultureInfos; + _contentName = contentData.Name; + _urlSegment = contentData.UrlSegment; + _published = contentData.Published; + + var properties = new IPublishedProperty[ContentNode.ContentType.PropertyTypes.Count()]; + var i = 0; + foreach (IPublishedPropertyType propertyType in ContentNode.ContentType.PropertyTypes) + { + // add one property per property type - this is required, for the indexing to work + // if contentData supplies pdatas, use them, else use null + contentData.Properties.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas); // else will be null + properties[i++] = new PublishedProperty(propertyType, this, variationContextAccessor, preview, propertyDatas, elementsCache, propertyType.CacheLevel); + } + + _properties = properties; + + Id = contentNode.Id; + Key = contentNode.Key; + CreatorId = contentNode.CreatorId; + CreateDate = contentNode.CreateDate; + SortOrder = contentNode.SortOrder; + WriterId = contentData.WriterId; + UpdateDate = contentData.VersionDate; + } + + protected ContentNode ContentNode { get; } + + protected ContentData ContentData { get; } + + public override IPublishedContentType ContentType => ContentNode.ContentType; + + public override Guid Key { get; } + + public override IEnumerable Properties => _properties; + + public override int Id { get; } + + /// + public string Name => this.Name(VariationContextAccessor); + + public override int SortOrder { get; } + + public override int CreatorId { get; } + + public override DateTime CreateDate { get; } + + public override int WriterId { get; } + + public override DateTime UpdateDate { get; } + + // Needed for publishedProperty + internal IVariationContextAccessor VariationContextAccessor { get; } + + /// + public override IReadOnlyDictionary Cultures + { + get + { + if (_cultures != null) + { + return _cultures; + } + + if (!ContentType.VariesByCulture()) + { + return _cultures = new Dictionary + { + { string.Empty, new PublishedCultureInfo(string.Empty, _contentName, _urlSegment, CreateDate) }, + }; + } + + if (_cultureInfos == null) + { + throw new PanicException("_contentDate.CultureInfos is null."); + } + + + return _cultures = _cultureInfos + .ToDictionary( + x => x.Key, + x => new PublishedCultureInfo(x.Key, x.Value.Name, x.Value.UrlSegment, x.Value.Date), + StringComparer.OrdinalIgnoreCase); + } + } + + /// + public override PublishedItemType ItemType => ContentNode.ContentType.ItemType; + + public override IPublishedProperty? GetProperty(string alias) + { + var index = ContentNode.ContentType.GetPropertyIndex(alias); + if (index < 0) + { + return null; // happens when 'alias' does not match a content type property alias + } + + // should never happen - properties array must be in sync with property type + if (index >= _properties.Length) + { + throw new IndexOutOfRangeException( + "Index points outside the properties array, which means the properties array is corrupt."); + } + + IPublishedProperty property = _properties[index]; + return property; + } + + public override bool IsDraft(string? culture = null) + { + // if this is the 'published' published content, nothing can be draft + if (_published) + { + return false; + } + + // not the 'published' published content, and does not vary = must be draft + if (!ContentType.VariesByCulture()) + { + return true; + } + + // handle context culture + culture ??= VariationContextAccessor?.VariationContext?.Culture ?? string.Empty; + + // not the 'published' published content, and varies + // = depends on the culture + return _cultureInfos is not null && _cultureInfos.TryGetValue(culture, out CultureVariation? cvar) && cvar.IsDraft; + } + + public override bool IsPublished(string? culture = null) + { + // whether we are the 'draft' or 'published' content, need to determine whether + // there is a 'published' version for the specified culture (or at all, for + // invariant content items) + + // if there is no 'published' published content, no culture can be published + if (!ContentNode.HasPublished) + { + return false; + } + + // if there is a 'published' published content, and does not vary = published + if (!ContentType.VariesByCulture()) + { + return true; + } + + // handle context culture + culture ??= VariationContextAccessor.VariationContext?.Culture ?? string.Empty; + + // there is a 'published' published content, and varies + // = depends on the culture + return ContentNode.HasPublishedCulture(culture); + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs index 17f37166307e..93b9df0686c5 100644 --- a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs @@ -10,8 +10,9 @@ namespace Umbraco.Cms.Infrastructure.HybridCache; internal sealed class PublishedProperty : PublishedPropertyBase { - private readonly PublishedContent _content; + private readonly IPublishedElement _element; private readonly bool _isPreviewing; + private readonly IVariationContextAccessor _variationContextAccessor; private readonly IElementsCache _elementsCache; private readonly bool _isMember; private string? _valuesCacheKey; @@ -35,7 +36,9 @@ internal sealed class PublishedProperty : PublishedPropertyBase // initializes a published content property with a value public PublishedProperty( IPublishedPropertyType propertyType, - PublishedContent content, + IPublishedElement element, + IVariationContextAccessor variationContextAccessor, + bool preview, PropertyData[]? sourceValues, IElementsCache elementsElementsCache, PropertyCacheLevel referenceCacheLevel = PropertyCacheLevel.Element) @@ -64,21 +67,22 @@ public PublishedProperty( } } - _content = content; - _isPreviewing = content.IsPreviewing; - _isMember = content.ContentType.ItemType == PublishedItemType.Member; + _element = element; + _variationContextAccessor = variationContextAccessor; + _isPreviewing = preview; + _isMember = element.ContentType.ItemType == PublishedItemType.Member; _elementsCache = elementsElementsCache; // this variable is used for contextualizing the variation level when calculating property values. // it must be set to the union of variance (the combination of content type and property type variance). - _variations = propertyType.Variations | content.ContentType.Variations; + _variations = propertyType.Variations | element.ContentType.Variations; _sourceVariations = propertyType.Variations; _propertyTypeAlias = propertyType.Alias; } // used to cache the CacheValues of this property - internal string ValuesCacheKey => _valuesCacheKey ??= PropertyCacheValues(_content.Key, Alias, _isPreviewing); + internal string ValuesCacheKey => _valuesCacheKey ??= PropertyCacheValues(_element.Key, Alias, _isPreviewing); private static string PropertyCacheValues(Guid contentUid, string typeAlias, bool previewing) { @@ -93,7 +97,7 @@ private static string PropertyCacheValues(Guid contentUid, string typeAlias, boo // determines whether a property has value public override bool HasValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(_variations, _element.Id, ref culture, ref segment); var value = GetSourceValue(culture, segment); var isValue = PropertyType.IsValue(value, PropertyValueLevel.Source); @@ -117,7 +121,7 @@ public override bool HasValue(string? culture = null, string? segment = null) public override object? GetSourceValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_sourceVariations, _content.Id, _propertyTypeAlias, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(_sourceVariations, _element.Id, ref culture, ref segment); // source values are tightly bound to the property/schema culture and segment configurations, so we need to // sanitize the contextualized culture/segment states before using them to access the source values. @@ -150,17 +154,17 @@ public override bool HasValue(string? culture = null, string? segment = null) return _interValue; } - _interValue = PropertyType.ConvertSourceToInter(_content, _sourceValue, _isPreviewing); + _interValue = PropertyType.ConvertSourceToInter(_element, _sourceValue, _isPreviewing); _interInitialized = true; return _interValue; } - return PropertyType.ConvertSourceToInter(_content, GetSourceValue(culture, segment), _isPreviewing); + return PropertyType.ConvertSourceToInter(_element, GetSourceValue(culture, segment), _isPreviewing); } public override object? GetValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(_variations, _element.Id, ref culture, ref segment); object? value; CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment); @@ -173,7 +177,7 @@ public override bool HasValue(string? culture = null, string? segment = null) return cacheValues.ObjectValue; } - cacheValues.ObjectValue = PropertyType.ConvertInterToObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing); + cacheValues.ObjectValue = PropertyType.ConvertInterToObject(_element, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing); cacheValues.ObjectInitialized = true; value = cacheValues.ObjectValue; @@ -223,7 +227,7 @@ private CacheValues GetCacheValues(IAppCache? cache) public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(_variations, _element.Id, ref culture, ref segment); object? value; CacheValue cacheValues = GetCacheValues(expanding ? PropertyType.DeliveryApiCacheLevelForExpansion : PropertyType.DeliveryApiCacheLevel).For(culture, segment); @@ -232,7 +236,7 @@ private CacheValues GetCacheValues(IAppCache? cache) // initial reference cache level always is .Content const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element; - object? GetDeliveryApiObject() => PropertyType.ConvertInterToDeliveryApiObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing, expanding); + object? GetDeliveryApiObject() => PropertyType.ConvertInterToDeliveryApiObject(_element, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing, expanding); value = expanding ? GetDeliveryApiExpandedObject(cacheValues, GetDeliveryApiObject) : GetDeliveryApiDefaultObject(cacheValues, GetDeliveryApiObject); diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/ElementCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/ElementCacheService.cs new file mode 100644 index 000000000000..9d472d264955 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Services/ElementCacheService.cs @@ -0,0 +1,58 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.HybridCache.Factories; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Services; + +// TODO ELEMENTS: implement IPublishedElement cache with an actual cache behind (see DocumentCacheService) +internal class ElementCacheService : IElementCacheService +{ + private readonly IElementService _elementService; + private readonly ICacheNodeFactory _cacheNodeFactory; + private readonly IPublishedContentFactory _publishedContentFactory; + private readonly IPublishedModelFactory _publishedModelFactory; + + public ElementCacheService( + IElementService elementService, + ICacheNodeFactory cacheNodeFactory, + IPublishedContentFactory publishedContentFactory, + IPublishedModelFactory publishedModelFactory) + { + _elementService = elementService; + _cacheNodeFactory = cacheNodeFactory; + _publishedContentFactory = publishedContentFactory; + _publishedModelFactory = publishedModelFactory; + } + + public Task GetByKeyAsync(Guid key, bool? preview = null) + { + IPublishedElement? result = null; + IElement? element = _elementService.GetById(key); + if (element is null || element.Trashed is true) + { + return Task.FromResult(result); + } + + if (preview is not true && element.Published is false) + { + return Task.FromResult(result); + } + + preview ??= false; + var cacheNode = _cacheNodeFactory.ToContentCacheNode(element, preview.Value); + result = _publishedContentFactory.ToIPublishedElement(cacheNode, preview.Value); + return Task.FromResult(result.CreateModel(_publishedModelFactory)); + } + + // TODO ELEMENTS: implement memory cache + public Task ClearMemoryCacheAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + // TODO ELEMENTS: implement memory cache + public Task RefreshMemoryCacheAsync(Guid key) => Task.CompletedTask; + + // TODO ELEMENTS: implement memory cache + public Task RemoveFromMemoryCacheAsync(Guid key) => Task.CompletedTask; +} diff --git a/src/Umbraco.Web.Common/Authorization/AuthorizationPolicies.cs b/src/Umbraco.Web.Common/Authorization/AuthorizationPolicies.cs index 3a203e3ad906..802b05ff032d 100644 --- a/src/Umbraco.Web.Common/Authorization/AuthorizationPolicies.cs +++ b/src/Umbraco.Web.Common/Authorization/AuthorizationPolicies.cs @@ -18,6 +18,8 @@ public static class AuthorizationPolicies public const string MediaPermissionByResource = nameof(MediaPermissionByResource); + public const string ElementPermissionByResource = nameof(ElementPermissionByResource); + // Single section access public const string SectionAccessContent = nameof(SectionAccessContent); public const string SectionAccessPackages = nameof(SectionAccessPackages); @@ -25,15 +27,18 @@ public static class AuthorizationPolicies public const string SectionAccessMedia = nameof(SectionAccessMedia); public const string SectionAccessSettings = nameof(SectionAccessSettings); public const string SectionAccessMembers = nameof(SectionAccessMembers); + public const string SectionAccessLibrary = nameof(SectionAccessLibrary); // Custom access based on multiple sections public const string SectionAccessContentOrMedia = nameof(SectionAccessContentOrMedia); public const string SectionAccessForMemberTree = nameof(SectionAccessForMemberTree); public const string SectionAccessForMediaTree = nameof(SectionAccessForMediaTree); public const string SectionAccessForContentTree = nameof(SectionAccessForContentTree); + public const string SectionAccessForElementTree = nameof(SectionAccessForElementTree); // Single tree access public const string TreeAccessDocuments = nameof(TreeAccessDocuments); + public const string TreeAccessElements = nameof(TreeAccessElements); public const string TreeAccessPartialViews = nameof(TreeAccessPartialViews); public const string TreeAccessDataTypes = nameof(TreeAccessDataTypes); public const string TreeAccessWebhooks = nameof(TreeAccessWebhooks); @@ -50,10 +55,12 @@ public static class AuthorizationPolicies // Custom access based on multiple trees public const string TreeAccessDocumentsOrDocumentTypes = nameof(TreeAccessDocumentsOrDocumentTypes); + public const string TreeAccessDocumentsOrElementsOrDocumentTypes = nameof(TreeAccessDocumentsOrElementsOrDocumentTypes); public const string TreeAccessMediaOrMediaTypes = nameof(TreeAccessMediaOrMediaTypes); public const string TreeAccessDictionaryOrTemplates = nameof(TreeAccessDictionaryOrTemplates); public const string TreeAccessDocumentOrMediaOrContentTypes = nameof(TreeAccessDocumentOrMediaOrContentTypes); public const string TreeAccessDocumentsOrMediaOrMembersOrContentTypes = nameof(TreeAccessDocumentsOrMediaOrMembersOrContentTypes); + public const string TreeAccessDocumentsOrElementsOrMediaOrMembersOrContentTypes = nameof(TreeAccessDocumentsOrElementsOrMediaOrMembersOrContentTypes); public const string TreeAccessStylesheetsOrDocumentOrMediaOrMember = nameof(TreeAccessStylesheetsOrDocumentOrMediaOrMember); public const string TreeAccessMembersOrMemberTypes = nameof(TreeAccessMembersOrMemberTypes); diff --git a/src/Umbraco.Web.UI.Client/.gitignore b/src/Umbraco.Web.UI.Client/.gitignore index af9bc97cb0c1..991ec4bab29a 100644 --- a/src/Umbraco.Web.UI.Client/.gitignore +++ b/src/Umbraco.Web.UI.Client/.gitignore @@ -55,3 +55,6 @@ vscode-html-custom-data.json # Vite runtime files vite.config.ts.timestamp-*.mjs .vite/ + +# AI tools +.claude/settings.local.json diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index d5f1dfaafec1..df0d3826109e 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -3655,6 +3655,10 @@ "resolved": "src/external/dompurify", "link": true }, + "node_modules/@umbraco-backoffice/element": { + "resolved": "src/packages/elements", + "link": true + }, "node_modules/@umbraco-backoffice/embedded-media": { "resolved": "src/packages/embedded-media", "link": true @@ -3683,6 +3687,10 @@ "resolved": "src/packages/language", "link": true }, + "node_modules/@umbraco-backoffice/library": { + "resolved": "src/packages/library", + "link": true + }, "node_modules/@umbraco-backoffice/lit": { "resolved": "src/external/lit", "link": true @@ -17149,6 +17157,9 @@ "src/packages/documents": { "name": "@umbraco-backoffice/document" }, + "src/packages/elements": { + "name": "@umbraco-backoffice/element" + }, "src/packages/embedded-media": { "name": "@umbraco-backoffice/embedded-media" }, @@ -17164,6 +17175,9 @@ "src/packages/language": { "name": "@umbraco-backoffice/language" }, + "src/packages/library": { + "name": "@umbraco-backoffice/library" + }, "src/packages/log-viewer": { "name": "@umbraco-backoffice/log-viewer" }, diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index d072f8f55f19..710fb8e2a16a 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -44,9 +44,11 @@ "./document-type": "./dist-cms/packages/documents/document-types/index.js", "./document": "./dist-cms/packages/documents/documents/index.js", "./dropzone": "./dist-cms/packages/media/dropzone/index.js", + "./element": "./dist-cms/packages/elements/index.js", "./entity-action": "./dist-cms/packages/core/entity-action/index.js", "./entity-bulk-action": "./dist-cms/packages/core/entity-bulk-action/index.js", "./entity-create-option-action": "./dist-cms/packages/core/entity-create-option-action/index.js", + "./entity-data-picker": "./dist-cms/packages/property-editors/entity-data-picker/index.js", "./entity-item": "./dist-cms/packages/core/entity-item/index.js", "./entity-sign": "./dist-cms/packages/core/entity-sign/index.js", "./entity-flag": "./dist-cms/packages/core/entity-flag/index.js", @@ -62,6 +64,7 @@ "./imaging": "./dist-cms/packages/media/imaging/index.js", "./interaction-memory": "./dist-cms/packages/core/interaction-memory/index.js", "./language": "./dist-cms/packages/language/index.js", + "./library": "./dist-cms/packages/library/index.js", "./lit-element": "./dist-cms/packages/core/lit-element/index.js", "./localization": "./dist-cms/packages/core/localization/index.js", "./log-viewer": "./dist-cms/packages/log-viewer/index.js", diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts index 558792cabc19..b60bbc89f1ec 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts @@ -20,10 +20,12 @@ const CORE_PACKAGES = [ import('../../packages/dictionary/umbraco-package.js'), import('../../packages/documents/umbraco-package.js'), import('../../packages/embedded-media/umbraco-package.js'), + import('../../packages/elements/umbraco-package.js'), import('../../packages/extension-insights/umbraco-package.js'), import('../../packages/health-check/umbraco-package.js'), import('../../packages/help/umbraco-package.js'), import('../../packages/language/umbraco-package.js'), + import('../../packages/library/umbraco-package.js'), import('../../packages/log-viewer/umbraco-package.js'), import('../../packages/management-api/umbraco-package.js'), import('../../packages/markdown-editor/umbraco-package.js'), 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 0018fce2b101..fb74556aa702 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -469,6 +469,8 @@ export default { documentType: 'Document Type', documentTypeDescription: 'The data definition for a content component that can be created by editors in the content tree and be picked on other pages but has no direct URL.', + element: 'Element', + elementDescription: 'Select the Element Type you want to make an element for', elementType: 'Element Type', elementTypeDescription: "Defines the schema for a repeating set of properties, for example, in a 'Block List' or 'Block Grid' property editor.", @@ -1437,6 +1439,7 @@ export default { }, sections: { content: 'Content', + library: 'Library', media: 'Media', member: 'Members', packages: 'Packages', @@ -1502,9 +1505,11 @@ export default { editContentPublishedFailedByValidation: 'Document could not be published, but we saved it for you', editContentPublishedFailedByParent: 'Document could not be published, because a parent page is not published', editContentPublishedHeader: 'Document published', + editElementPublishedHeader: 'Element published', editContentPublishedText: 'and is visible on the website', editContentUnpublishedHeader: 'Document unpublished', editContentUnpublishedText: 'and is no longer visible on the website', + editElementUnpublishedHeader: 'Element unpublished', editVariantPublishedText: '%0% published and is visible on the website', editVariantSavedText: '%0% saved', editBlueprintSavedHeader: 'Document Blueprint saved', @@ -2115,15 +2120,18 @@ export default { granularRightsLabel: 'Documents', granularRightsDescription: 'Assign permissions to specific documents', permissionsEntityGroup_document: 'Document permissions', + permissionsEntityGroup_element: 'Element permissions', permissionsEntityGroup_media: 'Media permissions', permissionsEntityGroup_member: 'Member permissions', 'permissionsEntityGroup_document-property-value': 'Document Property Value permissions', + 'permissionsEntityGroup_element-property-value': 'Element Property Value permissions', permissionNoVerbs: 'No allowed permissions', profile: 'Profile', searchAllChildren: 'Search all children', languagesHelp: 'Limit the languages users have access to edit', allowAccessToAllLanguages: 'Allow access to all languages', allowAccessToAllDocuments: 'Allow access to all documents', + allowAccessToAllElements: 'Allow access to all elements', allowAccessToAllMedia: 'Allow access to all media', sectionsHelp: 'Add sections to give users access', selectUserGroup: (multiple: boolean) => { @@ -2202,6 +2210,29 @@ export default { '2faCodeInput': 'Verification code', '2faCodeInputHelp': 'Please enter the verification code', '2faInvalidCode': 'Invalid code entered', + selectElementStartNode: 'Select element start node', + selectElementStartNodeDescription: 'Limit the element library to a specific start node', + }, + userPermissions: { + create: 'Create', + create_element: 'Allow access to create an element', + delete: 'Delete', + delete_element: 'Allow access to delete an element', + duplicate: 'Duplicate', + duplicate_element: 'Allow access to duplicate an element', + granular_element: 'Assign permissions to specific elements', + move: 'Move', + move_element: 'Allow access to move an element', + publish: 'Publish', + publish_element: 'Allow access to publish an element', + read: 'Read', + read_element: 'Allow access to read an element', + rollback: 'Rollback', + rollback_element: 'Allow access to rollback an element to a previous state', + unpublish: 'Unpublish', + unpublish_element: 'Allow access to unpublish an element', + update: 'Update', + update_element: 'Allow access to save an element', }, validation: { validation: 'Validation', @@ -2369,6 +2400,7 @@ export default { dashboardTabs: { contentIntro: 'Welcome to Umbraco', contentRedirectManager: 'Redirect URL Management', + libraryWelcome: 'Welcome', mediaFolderBrowser: 'Content', settingsWelcome: 'Welcome', settingsExamine: 'Examine Management', @@ -2570,6 +2602,9 @@ export default { errorDisablingProfilerDescription: 'It was not possible to disable the profiler. Try again, and if the problem persists, please check the log for more details.', }, + libraryDashboard: { + welcomeHeader: 'Welcome to the library', + }, settingsDashboard: { documentationHeader: 'Documentation', documentationDescription: 'Read more about working with the items in Settings in our Documentation.', diff --git a/src/Umbraco.Web.UI.Client/src/mocks/browser-handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/browser-handlers.ts index 495a91d0c212..6d06463b51eb 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/browser-handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/browser-handlers.ts @@ -5,6 +5,7 @@ import { handlers as dataTypeHandlers } from './handlers/data-type/index.js'; import { handlers as dictionaryHandlers } from './handlers/dictionary/index.js'; import { handlers as documentHandlers } from './handlers/document/index.js'; import { handlers as documentTypeHandlers } from './handlers/document-type/index.js'; +import { handlers as elementHandlers } from './handlers/element/index.js'; import { handlers as dynamicRootHandlers } from './handlers/dynamic-root.handlers.js'; import { handlers as examineManagementHandlers } from './handlers/examine-management.handlers.js'; import { handlers as healthCheckHandlers } from './handlers/health-check.handlers.js'; @@ -50,6 +51,7 @@ const handlers = [ ...dictionaryHandlers, ...documentHandlers, ...documentTypeHandlers, + ...elementHandlers, ...dynamicRootHandlers, ...examineManagementHandlers, ...healthCheckHandlers, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts index 14525d513f06..47253df01057 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts @@ -19,6 +19,7 @@ export const data: Array = [ isDeletable: true, canIgnoreStartNodes: false, flags: [], + noAccess: false, }, { name: 'Folder 2', @@ -32,6 +33,7 @@ export const data: Array = [ isDeletable: true, canIgnoreStartNodes: false, flags: [], + noAccess: false, }, { id: 'forbidden', @@ -45,6 +47,7 @@ export const data: Array = [ isDeletable: true, canIgnoreStartNodes: false, flags: [], + noAccess: false, }, { id: '0cc0eba1-9960-42c9-bf9b-60e150b429ae', @@ -58,6 +61,7 @@ export const data: Array = [ isDeletable: true, canIgnoreStartNodes: false, flags: [], + noAccess: false, }, { name: 'Text', @@ -76,6 +80,7 @@ export const data: Array = [ value: 10, }, ], + noAccess: false, }, { name: 'Text Area', @@ -89,6 +94,7 @@ export const data: Array = [ canIgnoreStartNodes: false, values: [], flags: [], + noAccess: false, }, { name: 'My JS Property Editor', @@ -102,6 +108,7 @@ export const data: Array = [ canIgnoreStartNodes: false, values: [], flags: [], + noAccess: false, }, { name: 'Color Picker', @@ -161,6 +168,7 @@ export const data: Array = [ ], }, ], + noAccess: false, }, { name: 'Content Picker', @@ -179,6 +187,7 @@ export const data: Array = [ value: { min: 2, max: 4 }, }, ], + noAccess: false, }, { name: 'Eye Dropper', @@ -219,6 +228,7 @@ export const data: Array = [ value: false, }, ], + noAccess: false, }, { name: 'Multi URL Picker', @@ -253,6 +263,7 @@ export const data: Array = [ value: 0, }, ], + noAccess: false, }, { name: 'Multi Node Tree Picker', @@ -299,6 +310,7 @@ export const data: Array = [ value: '', }, ], + noAccess: false, }, { name: 'Date Picker', @@ -325,6 +337,7 @@ export const data: Array = [ value: true, }, ], + noAccess: false, }, { name: 'Date Picker With Time', @@ -347,6 +360,7 @@ export const data: Array = [ value: true, }, ], + noAccess: false, }, { name: 'Time', @@ -369,6 +383,7 @@ export const data: Array = [ value: false, }, ], + noAccess: false, }, { name: 'Email', @@ -387,6 +402,7 @@ export const data: Array = [ value: 'email', }, ], + noAccess: false, }, { name: 'Multiple Text String', @@ -409,6 +425,7 @@ export const data: Array = [ value: 4, }, ], + noAccess: false, }, { name: 'Dropdown', @@ -431,6 +448,7 @@ export const data: Array = [ value: ['First Option', 'Second Option', 'I Am the third Option'], }, ], + noAccess: false, }, { name: 'Dropdown (Multiple)', @@ -453,6 +471,7 @@ export const data: Array = [ value: ['First Option', 'Second Option', 'I Am the third Option'], }, ], + noAccess: false, }, { name: 'Dropdown Alignment Options', @@ -475,6 +494,7 @@ export const data: Array = [ value: ['left', 'center', 'right'], }, ], + noAccess: false, }, { name: 'Slider', @@ -513,6 +533,7 @@ export const data: Array = [ value: 10, }, ], + noAccess: false, }, { name: 'Toggle', @@ -543,6 +564,7 @@ export const data: Array = [ value: true, }, ], + noAccess: false, }, { name: 'Tags', @@ -565,6 +587,7 @@ export const data: Array = [ value: [], }, ], + noAccess: false, }, { name: 'Code Editor', @@ -583,6 +606,7 @@ export const data: Array = [ value: 'html', }, ], + noAccess: false, }, { name: 'Markdown Editor', @@ -596,6 +620,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Radio Button List', @@ -614,6 +639,7 @@ export const data: Array = [ value: ['First Option', 'Second Option', 'I Am the third Option'], }, ], + noAccess: false, }, { name: 'Checkbox List', @@ -632,6 +658,7 @@ export const data: Array = [ value: ['First Option', 'Second Option', 'I Am the third Option'], }, ], + noAccess: false, }, { name: 'Block List', @@ -695,6 +722,7 @@ export const data: Array = [ value: true, }, ], + noAccess: false, }, { name: 'Media Picker', @@ -708,6 +736,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Image Cropper', @@ -742,6 +771,7 @@ export const data: Array = [ ], }, ], + noAccess: false, }, { name: 'Upload Field', @@ -764,6 +794,7 @@ export const data: Array = [ value: true, }, ], + noAccess: false, }, { name: 'Upload Field (Files)', @@ -786,6 +817,7 @@ export const data: Array = [ value: true, }, ], + noAccess: false, }, { name: 'Upload Field (Movies)', @@ -808,6 +840,7 @@ export const data: Array = [ value: true, }, ], + noAccess: false, }, { name: 'Upload Field (Vector)', @@ -830,6 +863,7 @@ export const data: Array = [ value: true, }, ], + noAccess: false, }, { name: 'Block Grid', @@ -914,7 +948,6 @@ export const data: Array = [ editorSize: 'medium', allowInAreas: true, }, - { label: 'Headline', contentElementTypeKey: 'headline-umbraco-demo-block-id', @@ -954,6 +987,7 @@ export const data: Array = [ ], }, ], + noAccess: false, }, { name: 'Collection View', @@ -997,6 +1031,7 @@ export const data: Array = [ { alias: 'tabName', value: 'Children' }, { alias: 'showContentFirst', value: true }, ], + noAccess: false, }, { name: 'Collection View - Media', @@ -1040,6 +1075,7 @@ export const data: Array = [ { alias: 'tabName', value: 'Items' }, { alias: 'showContentFirst', value: false }, ], + noAccess: false, }, { name: 'Icon Picker', @@ -1053,6 +1089,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Rich Text Editor', @@ -1152,6 +1189,7 @@ export const data: Array = [ { alias: 'ignoreUserStartNodes', value: false }, { alias: 'overlaySize', value: 'medium' }, ], + noAccess: false, }, { name: 'Label', @@ -1165,6 +1203,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Integer', @@ -1178,6 +1217,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Decimal', @@ -1196,6 +1236,7 @@ export const data: Array = [ value: 0.01, }, ], + noAccess: false, }, { name: 'User Picker', @@ -1209,6 +1250,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Member Picker', @@ -1222,6 +1264,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Member Group Picker', @@ -1235,6 +1278,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Data Type in folder', @@ -1248,6 +1292,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Static File Picker', @@ -1261,6 +1306,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Date Only', @@ -1274,6 +1320,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Time Only', @@ -1287,6 +1334,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Date Time (Unspecified)', @@ -1300,6 +1348,7 @@ export const data: Array = [ canIgnoreStartNodes: false, flags: [], values: [], + noAccess: false, }, { name: 'Date Time (with time zone)', @@ -1321,5 +1370,6 @@ export const data: Array = [ }, }, ], + noAccess: false, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.db.ts index 76910afc0acf..5067e2c9f064 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.db.ts @@ -73,6 +73,7 @@ const treeItemMapper = (model: UmbMockDataTypeModel): DataTypeTreeItemResponseMo isFolder: model.isFolder, isDeletable: model.isDeletable, flags: model.flags, + noAccess: model.noAccess, }; }; @@ -89,6 +90,7 @@ const createFolderMockMapper = (request: CreateFolderRequestModel): UmbMockDataT canIgnoreStartNodes: false, values: [], flags: [], + noAccess: false, }; }; @@ -105,6 +107,7 @@ const createDetailMockMapper = (request: CreateDataTypeRequestModel): UmbMockDat hasChildren: false, isDeletable: true, flags: [], + noAccess: false, }; }; 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 569e76b02024..e8b3fa44c730 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 @@ -43,6 +43,7 @@ export const data: Array = [ }, ], flags: [], + noAccess: false, }, { id: 'forbidden', @@ -77,5 +78,6 @@ export const data: Array = [ }, ], flags: [], + noAccess: false, }, ]; 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 0cbc7d8345e1..e054df892f7a 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 @@ -40,6 +40,7 @@ const treeItemMapper = (model: UmbMockDocumentBlueprintModel): DocumentBlueprint name: model.name, parent: model.parent, flags: model.flags, + noAccess: model.noAccess, }; }; @@ -77,6 +78,7 @@ const createMockDocumentBlueprintMapper = ( }; }), flags: [], + noAccess: false, }; }; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.data.ts index f78003f45092..54f52e9bf2aa 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.data.ts @@ -64,8 +64,8 @@ export const data: Array = [ keepLatestVersionPerDayForDays: null, }, flags: [], + noAccess: false, }, - { allowedTemplates: [], defaultTemplate: null, @@ -856,6 +856,7 @@ export const data: Array = [ keepAllVersionsNewerThanDays: null, keepLatestVersionPerDayForDays: null, }, + noAccess: false, }, { allowedTemplates: [], @@ -935,6 +936,7 @@ export const data: Array = [ keepLatestVersionPerDayForDays: null, }, collection: { id: 'dt-collectionView' }, + noAccess: false, }, { allowedTemplates: [], @@ -1105,6 +1107,7 @@ export const data: Array = [ keepAllVersionsNewerThanDays: null, keepLatestVersionPerDayForDays: null, }, + noAccess: false, }, { allowedTemplates: [{ id: '916cfecc-3295-490c-a16d-c41fa9f72980' }], @@ -1162,6 +1165,7 @@ export const data: Array = [ keepAllVersionsNewerThanDays: null, keepLatestVersionPerDayForDays: null, }, + noAccess: false, }, { allowedTemplates: [], @@ -1264,6 +1268,7 @@ export const data: Array = [ keepAllVersionsNewerThanDays: null, keepLatestVersionPerDayForDays: null, }, + noAccess: false, }, { allowedTemplates: [], @@ -1319,6 +1324,7 @@ export const data: Array = [ keepAllVersionsNewerThanDays: null, keepLatestVersionPerDayForDays: null, }, + noAccess: false, }, { allowedTemplates: [ @@ -1387,6 +1393,7 @@ export const data: Array = [ keepAllVersionsNewerThanDays: null, keepLatestVersionPerDayForDays: null, }, + noAccess: false, }, { allowedTemplates: [], @@ -1462,6 +1469,7 @@ export const data: Array = [ keepAllVersionsNewerThanDays: null, keepLatestVersionPerDayForDays: null, }, + noAccess: false, }, { allowedTemplates: [], @@ -1488,6 +1496,7 @@ export const data: Array = [ properties: [], containers: [], flags: [], + noAccess: false, }, { allowedTemplates: [], @@ -1563,6 +1572,7 @@ export const data: Array = [ sortOrder: 0, }, ], + noAccess: false, }, { allowedTemplates: [], @@ -1618,6 +1628,7 @@ export const data: Array = [ sortOrder: 0, }, ], + noAccess: false, }, { allowedTemplates: [], @@ -1673,6 +1684,7 @@ export const data: Array = [ sortOrder: 0, }, ], + noAccess: false, }, { allowedTemplates: [], @@ -1728,6 +1740,7 @@ export const data: Array = [ sortOrder: 0, }, ], + noAccess: false, }, { allowedTemplates: [], @@ -1783,6 +1796,7 @@ export const data: Array = [ sortOrder: 0, }, ], + noAccess: false, }, { allowedTemplates: [], @@ -1811,6 +1825,7 @@ export const data: Array = [ properties: [], containers: [], flags: [], + noAccess: false, }, { allowedTemplates: [], @@ -1837,6 +1852,7 @@ export const data: Array = [ flags: [], properties: [], containers: [], + noAccess: false, }, { allowedTemplates: [], @@ -1893,6 +1909,7 @@ export const data: Array = [ keepAllVersionsNewerThanDays: null, keepLatestVersionPerDayForDays: null, }, + noAccess: false, }, { allowedTemplates: [], @@ -1968,6 +1985,7 @@ export const data: Array = [ keepAllVersionsNewerThanDays: null, keepLatestVersionPerDayForDays: null, }, + noAccess: false, }, { allowedTemplates: [], @@ -2023,5 +2041,6 @@ export const data: Array = [ keepAllVersionsNewerThanDays: null, keepLatestVersionPerDayForDays: null, }, + noAccess: false, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.db.ts index 64a61c3b8961..068a2b25f994 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.db.ts @@ -72,6 +72,7 @@ const createMockDocumentTypeFolderMapper = (request: CreateFolderRequestModel): keepLatestVersionPerDayForDays: null, }, flags: [], + noAccess: false, }; }; @@ -100,6 +101,7 @@ const createMockDocumentTypeMapper = (request: CreateDocumentTypeRequestModel): keepLatestVersionPerDayForDays: null, }, flags: [], + noAccess: false, }; }; @@ -134,6 +136,7 @@ const documentTypeTreeItemMapper = (item: UmbMockDocumentTypeModel): DocumentTyp icon: item.icon, isElement: item.isElement, flags: item.flags, + noAccess: item.noAccess, }; }; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/element/element-publishing.manager.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/element/element-publishing.manager.ts new file mode 100644 index 000000000000..87e65cd5a913 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/element/element-publishing.manager.ts @@ -0,0 +1,63 @@ +import type { UmbMockElementModel } from './element.data.js'; +import type { UmbElementMockDB } from './element.db.js'; +import type { + PublishElementRequestModel, + UnpublishElementRequestModel, +} from '@umbraco-cms/backoffice/external/backend-api'; +import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; + +export class UmbMockElementPublishingManager { + #elementDb: UmbElementMockDB; + + constructor(elementDb: UmbElementMockDB) { + this.#elementDb = elementDb; + } + + publish(id: string, data: PublishElementRequestModel) { + const element: UmbMockElementModel = this.#elementDb.detail.read(id); + + data.publishSchedules.forEach((culture) => { + const publishTime = culture.schedule?.publishTime; + const unpublishTime = culture.schedule?.unpublishTime; + + if (publishTime && new Date(publishTime) < new Date()) { + throw new Error('Publish date cannot be in the past'); + } + + if (unpublishTime && new Date(unpublishTime) < new Date()) { + throw new Error('Unpublish date cannot be in the past'); + } + + if (unpublishTime && publishTime && new Date(unpublishTime) < new Date(publishTime)) { + throw new Error('Unpublish date cannot be before publish date'); + } + + const variant = element.variants.find((x) => x.culture === culture.culture); + if (variant) { + variant.state = DocumentVariantStateModel.PUBLISHED; + } + }); + + this.#elementDb.detail.update(id, element); + } + + unpublish(id: string, data: UnpublishElementRequestModel) { + const element: UmbMockElementModel = this.#elementDb.detail.read(id); + + if (data.cultures) { + data.cultures.forEach((culture) => { + const variant = element.variants.find((x) => x.culture === culture); + + if (variant) { + variant.state = DocumentVariantStateModel.DRAFT; + } + }); + } else { + element.variants.forEach((variant) => { + variant.state = DocumentVariantStateModel.DRAFT; + }); + } + + this.#elementDb.detail.update(id, element); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/element/element.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/element/element.data.ts new file mode 100644 index 000000000000..7d56e085c4ca --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/element/element.data.ts @@ -0,0 +1,158 @@ +import type { + ElementItemResponseModel, + ElementResponseModel, + ElementTreeItemResponseModel, + ElementVariantResponseModel, + DocumentTypeReferenceResponseModel, +} from '@umbraco-cms/backoffice/external/backend-api'; +import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; + +export type UmbMockElementModel = Omit & + Omit & + Omit & { + ancestors: Array<{ id: string }>; + createDate: string; + documentType: DocumentTypeReferenceResponseModel | null; + variants: Array; + }; + +export const data: Array = [ + { + ancestors: [], + id: 'simple-element-id', + createDate: '2024-01-15T10:00:00.000Z', + parent: null, + documentType: { + id: '4f68ba66-6fb2-4778-83b8-6ab4ca3a7c5c', + icon: 'icon-lab', + }, + hasChildren: false, + isTrashed: false, + isFolder: false, + name: 'Simple Element', + variants: [ + { + state: DocumentVariantStateModel.PUBLISHED, + culture: null, + segment: null, + name: 'Simple Element', + createDate: '2024-01-15T10:00:00.000Z', + updateDate: '2024-01-15T10:00:00.000Z', + publishDate: '2024-01-15T10:00:00.000Z', + }, + ], + values: [ + { + editorAlias: 'Umbraco.TextBox', + alias: 'elementProperty', + culture: null, + segment: null, + value: 'Simple Element Title', + }, + ], + flags: [], + noAccess: false, + }, + { + ancestors: [], + id: 'element-folder-id', + createDate: '2024-01-14T09:00:00.000Z', + parent: null, + documentType: null, + hasChildren: true, + isTrashed: false, + isFolder: true, + name: 'Element Folder', + variants: [], + values: [], + flags: [], + noAccess: false, + }, + { + ancestors: [{ id: 'element-folder-id' }], + id: 'element-in-folder-id', + createDate: '2024-01-16T14:00:00.000Z', + parent: { id: 'element-folder-id' }, + documentType: { + id: '4f68ba66-6fb2-4778-83b8-6ab4ca3a7c5c', + icon: 'icon-lab', + }, + hasChildren: false, + isTrashed: false, + isFolder: false, + name: 'Element In Folder', + variants: [ + { + state: DocumentVariantStateModel.PUBLISHED, + culture: null, + segment: null, + name: 'Element In Folder', + createDate: '2024-01-16T14:00:00.000Z', + updateDate: '2024-01-16T14:00:00.000Z', + publishDate: '2024-01-16T14:00:00.000Z', + }, + ], + values: [ + { + editorAlias: 'Umbraco.TextBox', + alias: 'elementProperty', + culture: null, + segment: null, + value: 'This is an element inside a folder.', + }, + ], + flags: [], + noAccess: false, + }, + { + ancestors: [], + id: 'element-subfolder-1-id', + createDate: '2024-01-14T09:00:00.000Z', + parent: { id: 'element-folder-id' }, + documentType: null, + hasChildren: true, + isTrashed: false, + isFolder: true, + name: 'Element Subfolder 1', + variants: [], + values: [], + flags: [], + noAccess: false, + }, + { + ancestors: [{ id: 'element-folder-id' }, { id: 'element-subfolder-1-id' }], + id: 'element-in-subfolder-1-id', + createDate: '2024-01-16T14:00:00.000Z', + parent: { id: 'element-subfolder-1-id' }, + documentType: { + id: '4f68ba66-6fb2-4778-83b8-6ab4ca3a7c5c', + icon: 'icon-lab', + }, + hasChildren: false, + isTrashed: false, + isFolder: false, + name: 'Element In Subfolder 1', + variants: [ + { + state: DocumentVariantStateModel.PUBLISHED, + culture: null, + segment: null, + name: 'Element In Subfolder 1', + createDate: '2024-01-16T14:00:00.000Z', + updateDate: '2024-01-16T14:00:00.000Z', + publishDate: '2024-01-16T14:00:00.000Z', + }, + ], + values: [ + { + editorAlias: 'Umbraco.TextBox', + alias: 'elementProperty', + culture: null, + segment: null, + value: 'This is an element inside a subfolder 1.', + }, + ], + flags: [], + noAccess: false, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/element/element.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/element/element.db.ts new file mode 100644 index 000000000000..929bdad400df --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/element/element.db.ts @@ -0,0 +1,182 @@ +import { UmbMockEntityTreeManager } from '../utils/entity/entity-tree.manager.js'; +import { UmbMockEntityItemManager } from '../utils/entity/entity-item.manager.js'; +import { UmbMockEntityDetailManager } from '../utils/entity/entity-detail.manager.js'; +import { UmbMockEntityFolderManager } from '../utils/entity/entity-folder.manager.js'; +import { UmbEntityMockDbBase } from '../utils/entity/entity-base.js'; +import { UmbEntityRecycleBin } from '../utils/entity/entity-recycle-bin.js'; +import { UmbMockElementPublishingManager } from './element-publishing.manager.js'; +import { data } from './element.data.js'; +import type { UmbMockElementModel } from './element.data.js'; +import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbId } from '@umbraco-cms/backoffice/id'; +import type { + CreateElementRequestModel, + CreateFolderRequestModel, + ElementConfigurationResponseModel, + ElementItemResponseModel, + ElementResponseModel, + ElementTreeItemResponseModel, + ElementValueResponseModel, + ElementRecycleBinItemResponseModel, +} from '@umbraco-cms/backoffice/external/backend-api'; + +export class UmbElementMockDB extends UmbEntityMockDbBase { + tree = new UmbMockEntityTreeManager(this, treeItemMapper); + item = new UmbMockEntityItemManager(this, itemMapper); + detail = new UmbMockEntityDetailManager(this, createMockElementMapper, detailResponseMapper); + folder = new UmbMockEntityFolderManager(this, createMockElementFolderMapper); + recycleBin = new UmbEntityRecycleBin(this.data, recycleBinItemMapper); + publishing = new UmbMockElementPublishingManager(this); + + constructor(data: Array) { + super(data); + } + + getConfiguration(): ElementConfigurationResponseModel { + return { + allowEditInvariantFromNonDefault: true, + allowNonExistingSegmentsCreation: false, + disableDeleteWhenReferenced: true, + disableUnpublishWhenReferenced: true, + }; + } +} + +const treeItemMapper = (model: UmbMockElementModel): ElementTreeItemResponseModel => { + return { + hasChildren: model.hasChildren, + id: model.id, + parent: model.parent, + flags: model.flags, + name: model.name, + isFolder: model.isFolder, + createDate: model.createDate, + documentType: model.documentType, + variants: model.variants, + noAccess: model.noAccess, + }; +}; + +const recycleBinItemMapper = (model: UmbMockElementModel): ElementRecycleBinItemResponseModel => { + return { + id: model.id, + createDate: model.createDate, + hasChildren: model.hasChildren, + parent: model.parent ? { id: model.parent.id } : null, + documentType: model.documentType, + variants: model.variants, + isFolder: model.isFolder, + name: model.name, + }; +}; + +const createMockElementMapper = (request: CreateElementRequestModel): UmbMockElementModel => { + const isRoot = request.parent === null || request.parent === undefined; + let ancestors: Array<{ id: string }> = []; + + if (!isRoot) { + const parentId = request.parent!.id; + const parentAncestors = umbElementMockDb.tree.getAncestorsOf({ descendantId: parentId }).map((ancestor) => { + return { + id: ancestor.id, + }; + }); + ancestors = [...parentAncestors, { id: parentId }]; + } + + const now = new Date().toISOString(); + + return { + ancestors, + documentType: { + id: request.documentType.id, + icon: 'icon-brick', + }, + hasChildren: false, + id: request.id ? request.id : UmbId.new(), + createDate: now, + isTrashed: false, + isFolder: false, + name: request.variants[0]?.name || 'Untitled Element', + parent: request.parent, + values: request.values as ElementValueResponseModel[], + variants: request.variants.map((variantRequest) => { + return { + culture: variantRequest.culture, + segment: variantRequest.segment, + name: variantRequest.name, + createDate: now, + updateDate: now, + state: DocumentVariantStateModel.DRAFT, + publishDate: null, + }; + }), + flags: [], + noAccess: false, + }; +}; + +const createMockElementFolderMapper = (request: CreateFolderRequestModel): UmbMockElementModel => { + const now = new Date().toISOString(); + + let ancestors: Array<{ id: string }> = []; + if (request.parent) { + const parentId = request.parent.id; + const parentAncestors = umbElementMockDb.tree.getAncestorsOf({ descendantId: parentId }).map((ancestor) => { + return { + id: ancestor.id, + }; + }); + ancestors = [...parentAncestors, { id: parentId }]; + } + + return { + ancestors, + documentType: null, + hasChildren: false, + id: request.id ? request.id : UmbId.new(), + createDate: now, + isTrashed: false, + isFolder: true, + name: request.name, + parent: request.parent, + values: [], + variants: [], + flags: [], + noAccess: false, + }; +}; + +const detailResponseMapper = (model: UmbMockElementModel): ElementResponseModel => { + return { + documentType: model.documentType!, + id: model.id, + isTrashed: model.isTrashed, + values: model.values, + variants: model.variants.map((v) => ({ + culture: v.culture, + segment: null, + name: v.name, + createDate: model.createDate, + updateDate: model.createDate, + state: v.state, + publishDate: null, + scheduledPublishDate: null, + scheduledUnpublishDate: null, + })), + flags: model.flags, + }; +}; + +const itemMapper = (model: UmbMockElementModel): ElementItemResponseModel => { + return { + documentType: model.documentType!, + hasChildren: model.hasChildren, + id: model.id, + parent: model.parent, + variants: model.variants, + flags: model.flags, + }; +}; + +export const umbElementMockDb = new UmbElementMockDB(data); diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/media-type/media-type.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/media-type/media-type.data.ts index f94ebc62b928..7d927dbb0b59 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/media-type/media-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/media-type/media-type.data.ts @@ -104,6 +104,7 @@ export const data: Array = [ collection: { id: 'dt-collectionView' }, isDeletable: false, aliasCanBeChanged: false, + noAccess: false, }, { name: 'Audio', @@ -155,6 +156,7 @@ export const data: Array = [ collection: { id: 'dt-collectionView' }, isDeletable: false, aliasCanBeChanged: false, + noAccess: false, }, { name: 'Vector Graphics', @@ -206,6 +208,7 @@ export const data: Array = [ collection: { id: 'dt-collectionView' }, isDeletable: false, aliasCanBeChanged: false, + noAccess: false, }, { name: 'Movie', @@ -257,6 +260,7 @@ export const data: Array = [ collection: { id: 'dt-collectionView' }, isDeletable: false, aliasCanBeChanged: false, + noAccess: false, }, { name: 'Media Type 5', @@ -308,6 +312,7 @@ export const data: Array = [ collection: { id: 'dt-collectionView' }, isDeletable: false, aliasCanBeChanged: false, + noAccess: false, }, { name: 'A Forbidden Media Type', @@ -359,5 +364,6 @@ export const data: Array = [ collection: { id: 'dt-collectionView' }, isDeletable: true, aliasCanBeChanged: false, + noAccess: false, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/media-type/media-type.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/media-type/media-type.db.ts index 5c4143e6dbb8..77f3934bdd14 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/media-type/media-type.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/media-type/media-type.db.ts @@ -91,6 +91,7 @@ const createMockMediaTypeFolderMapper = (request: CreateFolderRequestModel): Umb isDeletable: false, aliasCanBeChanged: false, flags: [], + noAccess: false, }; }; @@ -116,6 +117,7 @@ const createMockMediaTypeMapper = (request: CreateMediaTypeRequestModel): UmbMoc isDeletable: false, aliasCanBeChanged: false, flags: [], + noAccess: false, }; }; @@ -150,6 +152,7 @@ const mediaTypeTreeItemMapper = (item: UmbMockMediaTypeModel): MediaTypeTreeItem icon: item.icon, isDeletable: item.isDeletable, flags: item.flags, + noAccess: item.noAccess, }; }; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.data.ts index 91bd8a68110a..4e62c7646449 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.data.ts @@ -74,6 +74,7 @@ export const data: Array = [ sections: [ UMB_CONTENT_SECTION_ALIAS, 'Umb.Section.Media', + 'Umb.Section.Library', 'Umb.Section.Settings', 'Umb.Section.Members', 'Umb.Section.Packages', @@ -84,6 +85,7 @@ export const data: Array = [ hasAccessToAllLanguages: true, documentRootAccess: true, mediaRootAccess: true, + elementRootAccess: true, aliasCanBeChanged: false, isDeletable: false, flags: [], @@ -117,6 +119,7 @@ export const data: Array = [ hasAccessToAllLanguages: true, documentRootAccess: true, mediaRootAccess: true, + elementRootAccess: true, aliasCanBeChanged: true, isDeletable: true, flags: [], @@ -134,6 +137,7 @@ export const data: Array = [ hasAccessToAllLanguages: true, documentRootAccess: true, mediaRootAccess: true, + elementRootAccess: true, aliasCanBeChanged: false, isDeletable: false, flags: [], @@ -151,6 +155,7 @@ export const data: Array = [ hasAccessToAllLanguages: true, documentRootAccess: true, mediaRootAccess: true, + elementRootAccess: true, aliasCanBeChanged: true, isDeletable: true, flags: [], @@ -173,6 +178,7 @@ export const data: Array = [ hasAccessToAllLanguages: true, documentRootAccess: true, mediaRootAccess: true, + elementRootAccess: true, aliasCanBeChanged: true, isDeletable: true, flags: [], @@ -190,6 +196,7 @@ export const data: Array = [ hasAccessToAllLanguages: true, documentRootAccess: true, mediaRootAccess: true, + elementRootAccess: true, aliasCanBeChanged: false, isDeletable: false, flags: [], diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.db.ts index cfb3531f56e8..9d06fa474328 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/user-group/user-group.db.ts @@ -102,6 +102,8 @@ const createMockMapper = (item: CreateUserGroupRequestModel): UmbMockUserGroupMo alias: item.alias, documentRootAccess: item.documentRootAccess, documentStartNode: item.documentStartNode, + elementRootAccess: item.elementRootAccess, + elementStartNode: item.elementStartNode, hasAccessToAllLanguages: item.hasAccessToAllLanguages, icon: item.icon, id: UmbId.new(), @@ -123,6 +125,8 @@ const detailResponseMapper = (item: UmbMockUserGroupModel): UserGroupResponseMod alias: item.alias, documentRootAccess: item.documentRootAccess, documentStartNode: item.documentStartNode, + elementRootAccess: item.elementRootAccess, + elementStartNode: item.elementStartNode, hasAccessToAllLanguages: item.hasAccessToAllLanguages, icon: item.icon, id: item.id, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.data.ts index cf562a8da288..d64f0807a612 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.data.ts @@ -12,10 +12,12 @@ export const data: Array = [ avatarUrls: [], createDate: '3/13/2022', documentStartNodeIds: [], + elementStartNodeIds: [], email: 'noreply@umbraco.com', failedLoginAttempts: 946, hasDocumentRootAccess: true, hasMediaRootAccess: true, + hasElementRootAccess: true, id: 'bca6c733-a63d-4353-a271-9a8b6bcca8bd', isAdmin: true, kind: UserKindModel.DEFAULT, @@ -35,10 +37,12 @@ export const data: Array = [ avatarUrls: [], createDate: '2023-10-12T18:30:32.879Z', documentStartNodeIds: [{ id: 'simple-document-id' }], + elementStartNodeIds: [], email: 'awalker1@domain.com', failedLoginAttempts: 0, hasDocumentRootAccess: true, hasMediaRootAccess: true, + hasElementRootAccess: true, id: '82e11d3d-b91d-43c9-9071-34d28e62e81d', isAdmin: true, kind: UserKindModel.DEFAULT, @@ -58,10 +62,12 @@ export const data: Array = [ avatarUrls: [], createDate: '2023-10-12T18:30:32.879Z', documentStartNodeIds: [], + elementStartNodeIds: [], email: 'okim1@domain.com', failedLoginAttempts: 0, hasDocumentRootAccess: true, hasMediaRootAccess: true, + hasElementRootAccess: true, id: 'aa1d83a9-bc7f-47d2-b288-58d8a31f5017', isAdmin: false, kind: UserKindModel.DEFAULT, @@ -81,10 +87,12 @@ export const data: Array = [ avatarUrls: [], createDate: '2023-10-12T18:30:32.879Z', documentStartNodeIds: [], + elementStartNodeIds: [], email: 'enieves1@domain.com', failedLoginAttempts: 0, hasDocumentRootAccess: true, hasMediaRootAccess: true, + hasElementRootAccess: true, id: 'ff2f4a50-d3d4-4bc4-869d-c7948c160e54', isAdmin: false, kind: UserKindModel.DEFAULT, @@ -104,10 +112,12 @@ export const data: Array = [ avatarUrls: [], createDate: '2023-10-12T18:30:32.879Z', documentStartNodeIds: [], + elementStartNodeIds: [], email: 'jpatel1@domain.com', failedLoginAttempts: 25, hasDocumentRootAccess: true, hasMediaRootAccess: true, + hasElementRootAccess: true, id: 'c290c6d9-9f12-4838-8567-621b52a178de', isAdmin: false, kind: UserKindModel.DEFAULT, @@ -127,10 +137,12 @@ export const data: Array = [ avatarUrls: [], createDate: '2023-10-12T18:30:32.879Z', documentStartNodeIds: [], + elementStartNodeIds: [], email: 'forbidden@example.com', failedLoginAttempts: 0, hasDocumentRootAccess: true, hasMediaRootAccess: true, + hasElementRootAccess: true, id: 'forbidden', isAdmin: false, kind: UserKindModel.DEFAULT, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.db.ts index 98c6e973f1d8..bcd49a62ca1c 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/user/user.db.ts @@ -55,8 +55,10 @@ class UmbUserMockDB extends UmbEntityMockDbBase { id: user.id, documentStartNodeIds: user.documentStartNodeIds, mediaStartNodeIds: user.mediaStartNodeIds, + elementStartNodeIds: user.elementStartNodeIds, hasDocumentRootAccess: user.hasDocumentRootAccess, hasMediaRootAccess: user.hasMediaRootAccess, + hasElementRootAccess: user.hasElementRootAccess, }; } @@ -126,8 +128,10 @@ class UmbUserMockDB extends UmbEntityMockDbBase { languages: [], documentStartNodeIds: firstUser.documentStartNodeIds, mediaStartNodeIds: firstUser.mediaStartNodeIds, + elementStartNodeIds: firstUser.elementStartNodeIds, hasDocumentRootAccess: firstUser.hasDocumentRootAccess, hasMediaRootAccess: firstUser.hasMediaRootAccess, + hasElementRootAccess: firstUser.hasElementRootAccess, fallbackPermissions, permissions, allowedSections, @@ -260,8 +264,10 @@ const createMockMapper = (item: CreateUserRequestModel): UmbMockUserModel => { languageIsoCode: null, documentStartNodeIds: [], mediaStartNodeIds: [], + elementStartNodeIds: [], hasDocumentRootAccess: false, hasMediaRootAccess: false, + hasElementRootAccess: false, avatarUrls: [], state: UserStateModel.INACTIVE, failedLoginAttempts: 0, @@ -281,10 +287,12 @@ const detailResponseMapper = (item: UmbMockUserModel): UserResponseModel => { avatarUrls: item.avatarUrls, createDate: item.createDate, documentStartNodeIds: item.documentStartNodeIds, + elementStartNodeIds: item.elementStartNodeIds, email: item.email, failedLoginAttempts: item.failedLoginAttempts, hasDocumentRootAccess: item.hasDocumentRootAccess, hasMediaRootAccess: item.hasMediaRootAccess, + hasElementRootAccess: item.hasElementRootAccess, id: item.id, isAdmin: item.isAdmin, kind: item.kind, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/utils/entity/entity-folder.manager.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/utils/entity/entity-folder.manager.ts index 04fc1eee3756..e29cfa73ffdc 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/utils/entity/entity-folder.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/utils/entity/entity-folder.manager.ts @@ -54,6 +54,7 @@ export class UmbMockEntityFolderManager extends UmbEntityMockDbBase { tree; @@ -11,6 +11,21 @@ export class UmbEntityRecycleBin< this.tree = new UmbMockEntityTreeManager(this, treeItemMapper); } + emptyRecycleBin() { + const trashedItems = this.getAll().filter((item) => item.isTrashed); + trashedItems.forEach((item) => this.delete(item.id)); + } + + restore(id: string, parentId: string | null) { + const model = this.read(id); + if (!model) throw new Error(`Element with id ${id} not found`); + + model.isTrashed = false; + model.parent = parentId ? { id: parentId } : null; + + this.update(id, model); + } + trash(ids: string[]) { const models = ids.map((id) => this.read(id)).filter((model) => !!model) as Array; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/detail.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/detail.handlers.ts new file mode 100644 index 000000000000..21b49b04bbda --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/detail.handlers.ts @@ -0,0 +1,76 @@ +const { http, HttpResponse } = window.MockServiceWorker; +import { umbElementMockDb } from '../../data/element/element.db.js'; +import { UMB_SLUG } from './slug.js'; +import type { + CreateElementRequestModel, + UpdateElementRequestModel, +} from '@umbraco-cms/backoffice/external/backend-api'; +import { umbracoPath } from '@umbraco-cms/backoffice/utils'; + +export const detailHandlers = [ + http.post(umbracoPath(`${UMB_SLUG}`), async ({ request }) => { + const requestBody = (await request.json()) as CreateElementRequestModel; + if (!requestBody) return new HttpResponse(null, { status: 400, statusText: 'no body found' }); + + const id = umbElementMockDb.detail.create(requestBody); + + return HttpResponse.json(null, { + status: 201, + headers: { + Location: request.url + '/' + id, + 'Umb-Generated-Resource': id, + }, + }); + }), + + http.get(umbracoPath(`${UMB_SLUG}/configuration`), () => { + return HttpResponse.json(umbElementMockDb.getConfiguration()); + }), + + http.get(umbracoPath(`${UMB_SLUG}/:id`), ({ params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + if (id === 'forbidden') { + return new HttpResponse(null, { status: 403 }); + } + const response = umbElementMockDb.detail.read(id); + return HttpResponse.json(response); + }), + + http.put(umbracoPath(`${UMB_SLUG}/:id`), async ({ request, params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + if (id === 'forbidden') { + return new HttpResponse(null, { status: 403 }); + } + const requestBody = (await request.json()) as UpdateElementRequestModel; + if (!requestBody) return new HttpResponse(null, { status: 400, statusText: 'no body found' }); + umbElementMockDb.detail.update(id, requestBody); + return new HttpResponse(null, { status: 200 }); + }), + + http.delete(umbracoPath(`${UMB_SLUG}/:id`), ({ params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + if (id === 'forbidden') { + return new HttpResponse(null, { status: 403 }); + } + umbElementMockDb.detail.delete(id); + return new HttpResponse(null, { status: 200 }); + }), + + http.post(umbracoPath(`${UMB_SLUG}/validate`), async ({ request }) => { + const requestBody = (await request.json()) as CreateElementRequestModel; + if (!requestBody) return new HttpResponse(null, { status: 400, statusText: 'no body found' }); + return new HttpResponse(null, { status: 200 }); + }), + + http.put(umbracoPath(`${UMB_SLUG}/:id/validate`), async ({ params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + if (id === 'forbidden') { + return new HttpResponse(null, { status: 403 }); + } + return new HttpResponse(null, { status: 200 }); + }), +]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/folder.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/folder.handlers.ts new file mode 100644 index 000000000000..0bec8379361a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/folder.handlers.ts @@ -0,0 +1,72 @@ +const { http, HttpResponse } = window.MockServiceWorker; +import { umbElementMockDb } from '../../data/element/element.db.js'; +import { UMB_SLUG } from './slug.js'; +import { umbracoPath } from '@umbraco-cms/backoffice/utils'; +import type { + CreateFolderRequestModel, + UpdateFolderResponseModel, + MoveElementRequestModel, +} from '@umbraco-cms/backoffice/external/backend-api'; + +export const folderHandlers = [ + http.post(umbracoPath(`${UMB_SLUG}/folder`), async ({ request }) => { + const requestBody = await request.json(); + if (!requestBody) return new HttpResponse(null, { status: 400 }); + + const id = umbElementMockDb.folder.create(requestBody); + + return new HttpResponse(null, { + status: 201, + headers: { + Location: request.url + '/' + id, + 'Umb-Generated-Resource': id, + }, + }); + }), + + http.get(umbracoPath(`${UMB_SLUG}/folder/:id`), ({ params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + const response = umbElementMockDb.folder.read(id); + if (!response) return new HttpResponse(null, { status: 404 }); + return HttpResponse.json(response); + }), + + http.put<{ id: string }, UpdateFolderResponseModel>( + umbracoPath(`${UMB_SLUG}/folder/:id`), + async ({ request, params }) => { + const id = params.id; + if (!id) return new HttpResponse(null, { status: 400 }); + const requestBody = await request.json(); + if (!requestBody) return new HttpResponse(null, { status: 400 }); + umbElementMockDb.folder.update(id, requestBody); + return new HttpResponse(null, { status: 200 }); + }, + ), + + http.delete(umbracoPath(`${UMB_SLUG}/folder/:id`), ({ params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + umbElementMockDb.folder.delete(id); + return new HttpResponse(null, { status: 200 }); + }), + + http.put<{ id: string }, MoveElementRequestModel>( + umbracoPath(`${UMB_SLUG}/folder/:id/move`), + async ({ request, params }) => { + const id = params.id; + if (!id) return new HttpResponse(null, { status: 400 }); + const requestBody = await request.json(); + if (!requestBody) return new HttpResponse(null, { status: 400 }); + umbElementMockDb.tree.move([id], requestBody.target?.id ?? ''); + return new HttpResponse(null, { status: 200 }); + }, + ), + + http.put(umbracoPath(`${UMB_SLUG}/folder/:id/move-to-recycle-bin`), async ({ params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + umbElementMockDb.recycleBin.trash([id]); + return new HttpResponse(null, { status: 200 }); + }), +]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/index.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/index.ts new file mode 100644 index 000000000000..b58dff7c35ee --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/index.ts @@ -0,0 +1,17 @@ +import { recycleBinHandlers } from './recycle-bin.handlers.js'; +import { treeHandlers } from './tree.handlers.js'; +import { itemHandlers } from './item.handlers.js'; +import { publishingHandlers } from './publishing.handlers.js'; +import { detailHandlers } from './detail.handlers.js'; +import { folderHandlers } from './folder.handlers.js'; +import { moveCopyHandlers } from './move-copy.handlers.js'; + +export const handlers = [ + ...recycleBinHandlers, + ...treeHandlers, + ...itemHandlers, + ...publishingHandlers, + ...detailHandlers, + ...folderHandlers, + ...moveCopyHandlers, +]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/item.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/item.handlers.ts new file mode 100644 index 000000000000..cc98620c9987 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/item.handlers.ts @@ -0,0 +1,14 @@ +const { http, HttpResponse } = window.MockServiceWorker; +import { umbElementMockDb } from '../../data/element/element.db.js'; +import { UMB_SLUG } from './slug.js'; +import { umbracoPath } from '@umbraco-cms/backoffice/utils'; + +export const itemHandlers = [ + http.get(umbracoPath(`/item${UMB_SLUG}`), ({ request }) => { + const url = new URL(request.url); + const ids = url.searchParams.getAll('id'); + if (!ids) return; + const items = umbElementMockDb.item.getItems(ids); + return HttpResponse.json(items); + }), +]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/move-copy.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/move-copy.handlers.ts new file mode 100644 index 000000000000..8cb019d1f8a5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/move-copy.handlers.ts @@ -0,0 +1,44 @@ +const { http, HttpResponse } = window.MockServiceWorker; +import { umbElementMockDb } from '../../data/element/element.db.js'; +import { UMB_SLUG } from './slug.js'; +import type { CopyElementRequestModel, MoveElementRequestModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { umbracoPath } from '@umbraco-cms/backoffice/utils'; + +export const moveCopyHandlers = [ + http.put<{ id: string }, MoveElementRequestModel>( + umbracoPath(`${UMB_SLUG}/:id/move`), + async ({ request, params }) => { + const id = params.id; + if (!id) return new HttpResponse(null, { status: 400 }); + if (id === 'forbidden') { + return new HttpResponse(null, { status: 403 }); + } + const requestBody = await request.json(); + if (!requestBody) return new HttpResponse(null, { status: 400 }); + umbElementMockDb.tree.move([id], requestBody.target?.id ?? ''); + return new HttpResponse(null, { status: 200 }); + }, + ), + + http.post<{ id: string }, CopyElementRequestModel>( + umbracoPath(`${UMB_SLUG}/:id/copy`), + async ({ request, params }) => { + const id = params.id; + if (!id) return new HttpResponse(null, { status: 400 }); + if (id === 'forbidden') { + return new HttpResponse(null, { status: 403 }); + } + const requestBody = await request.json(); + if (!requestBody) return new HttpResponse(null, { status: 400 }); + const newIds = umbElementMockDb.tree.copy([id], requestBody.target?.id ?? ''); + const newId = newIds[0]; + return new HttpResponse(null, { + status: 201, + headers: { + Location: request.url.replace(`/${id}/copy`, `/${newId}`), + 'Umb-Generated-Resource': newId, + }, + }); + }, + ), +]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/publishing.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/publishing.handlers.ts new file mode 100644 index 000000000000..3585fc1c3318 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/publishing.handlers.ts @@ -0,0 +1,37 @@ +const { http, HttpResponse } = window.MockServiceWorker; +import { createProblemDetails } from '../../data/utils.js'; +import { umbElementMockDb } from '../../data/element/element.db.js'; +import { UMB_SLUG } from './slug.js'; +import type { + PublishElementRequestModel, + UnpublishElementRequestModel, +} from '@umbraco-cms/backoffice/external/backend-api'; +import { umbracoPath } from '@umbraco-cms/backoffice/utils'; + +export const publishingHandlers = [ + http.put(umbracoPath(`${UMB_SLUG}/:id/publish`), async ({ request, params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + const requestBody = (await request.json()) as PublishElementRequestModel; + if (!requestBody) return new HttpResponse(null, { status: 400, statusText: 'no body found' }); + + try { + umbElementMockDb.publishing.publish(id, requestBody); + return new HttpResponse(null, { status: 200 }); + } catch (error) { + if (error instanceof Error) { + return HttpResponse.json(createProblemDetails({ title: 'Publish', detail: error.message }), { status: 400 }); + } + throw new Error('An error occurred while publishing the element'); + } + }), + + http.put(umbracoPath(`${UMB_SLUG}/:id/unpublish`), async ({ request, params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + const requestBody = (await request.json()) as UnpublishElementRequestModel; + if (!requestBody) return new HttpResponse(null, { status: 400, statusText: 'no body found' }); + umbElementMockDb.publishing.unpublish(id, requestBody); + return new HttpResponse(null, { status: 200 }); + }), +]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/recycle-bin.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/recycle-bin.handlers.ts new file mode 100644 index 000000000000..af351b4621c4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/recycle-bin.handlers.ts @@ -0,0 +1,75 @@ +const { http, HttpResponse } = window.MockServiceWorker; +import { umbElementMockDb } from '../../data/element/element.db.js'; +import { UMB_SLUG } from './slug.js'; +import { umbracoPath } from '@umbraco-cms/backoffice/utils'; + +export const recycleBinHandlers = [ + http.get(umbracoPath(`/recycle-bin${UMB_SLUG}/root`), ({ request }) => { + const url = new URL(request.url); + const skip = Number(url.searchParams.get('skip')); + const take = Number(url.searchParams.get('take')); + const response = umbElementMockDb.recycleBin.tree.getRoot({ skip, take }); + return HttpResponse.json(response); + }), + + http.get(umbracoPath(`/recycle-bin${UMB_SLUG}/children`), ({ request }) => { + const url = new URL(request.url); + const parentId = url.searchParams.get('parentId'); + if (!parentId) return; + const skip = Number(url.searchParams.get('skip')); + const take = Number(url.searchParams.get('take')); + const response = umbElementMockDb.recycleBin.tree.getChildrenOf({ parentId, skip, take }); + return HttpResponse.json(response); + }), + + http.get(umbracoPath(`/recycle-bin${UMB_SLUG}/siblings`), ({ request }) => { + const url = new URL(request.url); + const id = url.searchParams.get('id'); + if (!id) return new HttpResponse(null, { status: 400 }); + + const item = umbElementMockDb.recycleBin.read(id); + if (!item) return new HttpResponse(null, { status: 404 }); + + const parentId = item.parent?.id; + const skip = Number(url.searchParams.get('skip') ?? 0); + const take = Number(url.searchParams.get('take') ?? 100); + + let siblings; + if (parentId) { + siblings = umbElementMockDb.recycleBin.tree.getChildrenOf({ parentId, skip, take }); + } else { + siblings = umbElementMockDb.recycleBin.tree.getRoot({ skip, take }); + } + + return HttpResponse.json({ + total: siblings.total, + items: siblings.items, + }); + }), + + http.put(umbracoPath(`${UMB_SLUG}/:id/move-to-recycle-bin`), async ({ params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + umbElementMockDb.recycleBin.trash([id]); + return new HttpResponse(null, { status: 200 }); + }), + + http.delete(umbracoPath(`/recycle-bin${UMB_SLUG}/:id`), ({ params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + umbElementMockDb.recycleBin.delete(id); + return new HttpResponse(null, { status: 200 }); + }), + + http.delete(umbracoPath(`/recycle-bin${UMB_SLUG}`), () => { + umbElementMockDb.recycleBin.emptyRecycleBin(); + return new HttpResponse(null, { status: 200 }); + }), + + http.delete(umbracoPath(`/recycle-bin${UMB_SLUG}/folder/:id`), ({ params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + umbElementMockDb.recycleBin.delete(id); + return new HttpResponse(null, { status: 200 }); + }), +]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/slug.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/slug.ts new file mode 100644 index 000000000000..b74a328db9a2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/slug.ts @@ -0,0 +1 @@ +export const UMB_SLUG = '/element'; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/tree.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/tree.handlers.ts new file mode 100644 index 000000000000..c38f1a2b59b4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/element/tree.handlers.ts @@ -0,0 +1,58 @@ +const { http, HttpResponse } = window.MockServiceWorker; +import { umbElementMockDb } from '../../data/element/element.db.js'; +import { UMB_SLUG } from './slug.js'; +import type { GetTreeElementAncestorsResponse } from '@umbraco-cms/backoffice/external/backend-api'; +import { umbracoPath } from '@umbraco-cms/backoffice/utils'; + +export const treeHandlers = [ + http.get(umbracoPath(`/tree${UMB_SLUG}/root`), ({ request }) => { + const url = new URL(request.url); + const skip = Number(url.searchParams.get('skip')); + const take = Number(url.searchParams.get('take')); + const response = umbElementMockDb.tree.getRoot({ skip, take }); + return HttpResponse.json(response); + }), + + http.get(umbracoPath(`/tree${UMB_SLUG}/children`), ({ request }) => { + const url = new URL(request.url); + const parentId = url.searchParams.get('parentId'); + if (!parentId) return; + const skip = Number(url.searchParams.get('skip')); + const take = Number(url.searchParams.get('take')); + const response = umbElementMockDb.tree.getChildrenOf({ parentId, skip, take }); + return HttpResponse.json(response); + }), + + http.get(umbracoPath(`/tree${UMB_SLUG}/ancestors`), ({ request }) => { + const url = new URL(request.url); + const descendantId = url.searchParams.get('descendantId'); + if (!descendantId) return; + const response = umbElementMockDb.tree.getAncestorsOf({ descendantId }); + return HttpResponse.json(response); + }), + + http.get(umbracoPath(`/tree${UMB_SLUG}/siblings`), ({ request }) => { + const url = new URL(request.url); + const id = url.searchParams.get('id'); + if (!id) return new HttpResponse(null, { status: 400 }); + + const item = umbElementMockDb.read(id); + if (!item) return new HttpResponse(null, { status: 404 }); + + const parentId = item.parent?.id; + const skip = Number(url.searchParams.get('skip') ?? 0); + const take = Number(url.searchParams.get('take') ?? 100); + + let siblings; + if (parentId) { + siblings = umbElementMockDb.tree.getChildrenOf({ parentId, skip, take }); + } else { + siblings = umbElementMockDb.tree.getRoot({ skip, take }); + } + + return HttpResponse.json({ + total: siblings.total, + items: siblings.items, + }); + }), +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts index e2b241745aad..ea6884c928a0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { client } from './client.gen'; -import type { DeleteDataTypeByIdData, DeleteDataTypeByIdErrors, DeleteDataTypeByIdResponses, DeleteDataTypeFolderByIdData, DeleteDataTypeFolderByIdErrors, DeleteDataTypeFolderByIdResponses, DeleteDictionaryByIdData, DeleteDictionaryByIdErrors, DeleteDictionaryByIdResponses, DeleteDocumentBlueprintByIdData, DeleteDocumentBlueprintByIdErrors, DeleteDocumentBlueprintByIdResponses, DeleteDocumentBlueprintFolderByIdData, DeleteDocumentBlueprintFolderByIdErrors, DeleteDocumentBlueprintFolderByIdResponses, DeleteDocumentByIdData, DeleteDocumentByIdErrors, DeleteDocumentByIdPublicAccessData, DeleteDocumentByIdPublicAccessErrors, DeleteDocumentByIdPublicAccessResponses, DeleteDocumentByIdResponses, DeleteDocumentTypeByIdData, DeleteDocumentTypeByIdErrors, DeleteDocumentTypeByIdResponses, DeleteDocumentTypeFolderByIdData, DeleteDocumentTypeFolderByIdErrors, DeleteDocumentTypeFolderByIdResponses, DeleteLanguageByIsoCodeData, DeleteLanguageByIsoCodeErrors, DeleteLanguageByIsoCodeResponses, DeleteLogViewerSavedSearchByNameData, DeleteLogViewerSavedSearchByNameErrors, DeleteLogViewerSavedSearchByNameResponses, DeleteMediaByIdData, DeleteMediaByIdErrors, DeleteMediaByIdResponses, DeleteMediaTypeByIdData, DeleteMediaTypeByIdErrors, DeleteMediaTypeByIdResponses, DeleteMediaTypeFolderByIdData, DeleteMediaTypeFolderByIdErrors, DeleteMediaTypeFolderByIdResponses, DeleteMemberByIdData, DeleteMemberByIdErrors, DeleteMemberByIdResponses, DeleteMemberGroupByIdData, DeleteMemberGroupByIdErrors, DeleteMemberGroupByIdResponses, DeleteMemberTypeByIdData, DeleteMemberTypeByIdErrors, DeleteMemberTypeByIdResponses, DeleteMemberTypeFolderByIdData, DeleteMemberTypeFolderByIdErrors, DeleteMemberTypeFolderByIdResponses, DeletePackageCreatedByIdData, DeletePackageCreatedByIdErrors, DeletePackageCreatedByIdResponses, DeletePartialViewByPathData, DeletePartialViewByPathErrors, DeletePartialViewByPathResponses, DeletePartialViewFolderByPathData, DeletePartialViewFolderByPathErrors, DeletePartialViewFolderByPathResponses, DeletePreviewData, DeletePreviewResponses, DeleteRecycleBinDocumentByIdData, DeleteRecycleBinDocumentByIdErrors, DeleteRecycleBinDocumentByIdResponses, DeleteRecycleBinDocumentData, DeleteRecycleBinDocumentErrors, DeleteRecycleBinDocumentResponses, DeleteRecycleBinMediaByIdData, DeleteRecycleBinMediaByIdErrors, DeleteRecycleBinMediaByIdResponses, DeleteRecycleBinMediaData, DeleteRecycleBinMediaErrors, DeleteRecycleBinMediaResponses, DeleteRedirectManagementByIdData, DeleteRedirectManagementByIdErrors, DeleteRedirectManagementByIdResponses, DeleteScriptByPathData, DeleteScriptByPathErrors, DeleteScriptByPathResponses, DeleteScriptFolderByPathData, DeleteScriptFolderByPathErrors, DeleteScriptFolderByPathResponses, DeleteStylesheetByPathData, DeleteStylesheetByPathErrors, DeleteStylesheetByPathResponses, DeleteStylesheetFolderByPathData, DeleteStylesheetFolderByPathErrors, DeleteStylesheetFolderByPathResponses, DeleteTemplateByIdData, DeleteTemplateByIdErrors, DeleteTemplateByIdResponses, DeleteTemporaryFileByIdData, DeleteTemporaryFileByIdErrors, DeleteTemporaryFileByIdResponses, DeleteUserAvatarByIdData, DeleteUserAvatarByIdErrors, DeleteUserAvatarByIdResponses, DeleteUserById2FaByProviderNameData, DeleteUserById2FaByProviderNameErrors, DeleteUserById2FaByProviderNameResponses, DeleteUserByIdClientCredentialsByClientIdData, DeleteUserByIdClientCredentialsByClientIdErrors, DeleteUserByIdClientCredentialsByClientIdResponses, DeleteUserByIdData, DeleteUserByIdErrors, DeleteUserByIdResponses, DeleteUserCurrent2FaByProviderNameData, DeleteUserCurrent2FaByProviderNameErrors, DeleteUserCurrent2FaByProviderNameResponses, DeleteUserData, DeleteUserDataByIdData, DeleteUserDataByIdErrors, DeleteUserDataByIdResponses, DeleteUserErrors, DeleteUserGroupByIdData, DeleteUserGroupByIdErrors, DeleteUserGroupByIdResponses, DeleteUserGroupByIdUsersData, DeleteUserGroupByIdUsersErrors, DeleteUserGroupByIdUsersResponses, DeleteUserGroupData, DeleteUserGroupErrors, DeleteUserGroupResponses, DeleteUserResponses, DeleteWebhookByIdData, DeleteWebhookByIdErrors, DeleteWebhookByIdResponses, GetCollectionDocumentByIdData, GetCollectionDocumentByIdErrors, GetCollectionDocumentByIdResponses, GetCollectionMediaData, GetCollectionMediaErrors, GetCollectionMediaResponses, GetCultureData, GetCultureErrors, GetCultureResponses, GetDataTypeByIdData, GetDataTypeByIdErrors, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedErrors, GetDataTypeByIdIsUsedResponses, GetDataTypeByIdReferencedByData, GetDataTypeByIdReferencedByErrors, GetDataTypeByIdReferencedByResponses, GetDataTypeByIdResponses, GetDataTypeConfigurationData, GetDataTypeConfigurationErrors, GetDataTypeConfigurationResponses, GetDataTypeFolderByIdData, GetDataTypeFolderByIdErrors, GetDataTypeFolderByIdResponses, GetDictionaryByIdData, GetDictionaryByIdErrors, GetDictionaryByIdExportData, GetDictionaryByIdExportErrors, GetDictionaryByIdExportResponses, GetDictionaryByIdResponses, GetDictionaryData, GetDictionaryErrors, GetDictionaryResponses, GetDocumentAreReferencedData, GetDocumentAreReferencedErrors, GetDocumentAreReferencedResponses, GetDocumentBlueprintByIdData, GetDocumentBlueprintByIdErrors, GetDocumentBlueprintByIdResponses, GetDocumentBlueprintByIdScaffoldData, GetDocumentBlueprintByIdScaffoldErrors, GetDocumentBlueprintByIdScaffoldResponses, GetDocumentBlueprintFolderByIdData, GetDocumentBlueprintFolderByIdErrors, GetDocumentBlueprintFolderByIdResponses, GetDocumentByIdAuditLogData, GetDocumentByIdAuditLogErrors, GetDocumentByIdAuditLogResponses, GetDocumentByIdAvailableSegmentOptionsData, GetDocumentByIdAvailableSegmentOptionsErrors, GetDocumentByIdAvailableSegmentOptionsResponses, GetDocumentByIdData, GetDocumentByIdDomainsData, GetDocumentByIdDomainsErrors, GetDocumentByIdDomainsResponses, GetDocumentByIdErrors, GetDocumentByIdNotificationsData, GetDocumentByIdNotificationsErrors, GetDocumentByIdNotificationsResponses, GetDocumentByIdPreviewUrlData, GetDocumentByIdPreviewUrlErrors, GetDocumentByIdPreviewUrlResponses, GetDocumentByIdPublicAccessData, GetDocumentByIdPublicAccessErrors, GetDocumentByIdPublicAccessResponses, GetDocumentByIdPublishedData, GetDocumentByIdPublishedErrors, GetDocumentByIdPublishedResponses, GetDocumentByIdPublishWithDescendantsResultByTaskIdData, GetDocumentByIdPublishWithDescendantsResultByTaskIdErrors, GetDocumentByIdPublishWithDescendantsResultByTaskIdResponses, GetDocumentByIdReferencedByData, GetDocumentByIdReferencedByErrors, GetDocumentByIdReferencedByResponses, GetDocumentByIdReferencedDescendantsData, GetDocumentByIdReferencedDescendantsErrors, GetDocumentByIdReferencedDescendantsResponses, GetDocumentByIdResponses, GetDocumentConfigurationData, GetDocumentConfigurationErrors, GetDocumentConfigurationResponses, GetDocumentTypeAllowedAtRootData, GetDocumentTypeAllowedAtRootErrors, GetDocumentTypeAllowedAtRootResponses, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenErrors, GetDocumentTypeByIdAllowedChildrenResponses, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintErrors, GetDocumentTypeByIdBlueprintResponses, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesErrors, GetDocumentTypeByIdCompositionReferencesResponses, GetDocumentTypeByIdData, GetDocumentTypeByIdErrors, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportErrors, GetDocumentTypeByIdExportResponses, GetDocumentTypeByIdResponses, GetDocumentTypeConfigurationData, GetDocumentTypeConfigurationErrors, GetDocumentTypeConfigurationResponses, GetDocumentTypeFolderByIdData, GetDocumentTypeFolderByIdErrors, GetDocumentTypeFolderByIdResponses, GetDocumentUrlsData, GetDocumentUrlsErrors, GetDocumentUrlsResponses, GetDocumentVersionByIdData, GetDocumentVersionByIdErrors, GetDocumentVersionByIdResponses, GetDocumentVersionData, GetDocumentVersionErrors, GetDocumentVersionResponses, GetDynamicRootStepsData, GetDynamicRootStepsErrors, GetDynamicRootStepsResponses, GetFilterDataTypeData, GetFilterDataTypeErrors, GetFilterDataTypeResponses, GetFilterMemberData, GetFilterMemberErrors, GetFilterMemberResponses, GetFilterUserData, GetFilterUserErrors, GetFilterUserGroupData, GetFilterUserGroupErrors, GetFilterUserGroupResponses, GetFilterUserResponses, GetHealthCheckGroupByNameData, GetHealthCheckGroupByNameErrors, GetHealthCheckGroupByNameResponses, GetHealthCheckGroupData, GetHealthCheckGroupErrors, GetHealthCheckGroupResponses, GetHelpData, GetHelpErrors, GetHelpResponses, GetImagingResizeUrlsData, GetImagingResizeUrlsErrors, GetImagingResizeUrlsResponses, GetImportAnalyzeData, GetImportAnalyzeErrors, GetImportAnalyzeResponses, GetIndexerByIndexNameData, GetIndexerByIndexNameErrors, GetIndexerByIndexNameResponses, GetIndexerData, GetIndexerErrors, GetIndexerResponses, GetInstallSettingsData, GetInstallSettingsErrors, GetInstallSettingsResponses, GetItemDataTypeData, GetItemDataTypeErrors, GetItemDataTypeResponses, GetItemDataTypeSearchData, GetItemDataTypeSearchErrors, GetItemDataTypeSearchResponses, GetItemDictionaryData, GetItemDictionaryErrors, GetItemDictionaryResponses, GetItemDocumentBlueprintData, GetItemDocumentBlueprintErrors, GetItemDocumentBlueprintResponses, GetItemDocumentData, GetItemDocumentErrors, GetItemDocumentResponses, GetItemDocumentSearchData, GetItemDocumentSearchErrors, GetItemDocumentSearchResponses, GetItemDocumentTypeData, GetItemDocumentTypeErrors, GetItemDocumentTypeResponses, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchErrors, GetItemDocumentTypeSearchResponses, GetItemLanguageData, GetItemLanguageDefaultData, GetItemLanguageDefaultErrors, GetItemLanguageDefaultResponses, GetItemLanguageErrors, GetItemLanguageResponses, GetItemMediaData, GetItemMediaErrors, GetItemMediaResponses, GetItemMediaSearchData, GetItemMediaSearchErrors, GetItemMediaSearchResponses, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedErrors, GetItemMediaTypeAllowedResponses, GetItemMediaTypeData, GetItemMediaTypeErrors, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersErrors, GetItemMediaTypeFoldersResponses, GetItemMediaTypeResponses, GetItemMediaTypeSearchData, GetItemMediaTypeSearchErrors, GetItemMediaTypeSearchResponses, GetItemMemberData, GetItemMemberErrors, GetItemMemberGroupData, GetItemMemberGroupErrors, GetItemMemberGroupResponses, GetItemMemberResponses, GetItemMemberSearchData, GetItemMemberSearchErrors, GetItemMemberSearchResponses, GetItemMemberTypeData, GetItemMemberTypeErrors, GetItemMemberTypeResponses, GetItemMemberTypeSearchData, GetItemMemberTypeSearchErrors, GetItemMemberTypeSearchResponses, GetItemPartialViewData, GetItemPartialViewErrors, GetItemPartialViewResponses, GetItemRelationTypeData, GetItemRelationTypeErrors, GetItemRelationTypeResponses, GetItemScriptData, GetItemScriptErrors, GetItemScriptResponses, GetItemStaticFileData, GetItemStaticFileErrors, GetItemStaticFileResponses, GetItemStylesheetData, GetItemStylesheetErrors, GetItemStylesheetResponses, GetItemTemplateData, GetItemTemplateErrors, GetItemTemplateResponses, GetItemTemplateSearchData, GetItemTemplateSearchErrors, GetItemTemplateSearchResponses, GetItemUserData, GetItemUserErrors, GetItemUserGroupData, GetItemUserGroupErrors, GetItemUserGroupResponses, GetItemUserResponses, GetItemWebhookData, GetItemWebhookErrors, GetItemWebhookResponses, GetLanguageByIsoCodeData, GetLanguageByIsoCodeErrors, GetLanguageByIsoCodeResponses, GetLanguageData, GetLanguageErrors, GetLanguageResponses, GetLogViewerLevelCountData, GetLogViewerLevelCountErrors, GetLogViewerLevelCountResponses, GetLogViewerLevelData, GetLogViewerLevelErrors, GetLogViewerLevelResponses, GetLogViewerLogData, GetLogViewerLogErrors, GetLogViewerLogResponses, GetLogViewerMessageTemplateData, GetLogViewerMessageTemplateErrors, GetLogViewerMessageTemplateResponses, GetLogViewerSavedSearchByNameData, GetLogViewerSavedSearchByNameErrors, GetLogViewerSavedSearchByNameResponses, GetLogViewerSavedSearchData, GetLogViewerSavedSearchErrors, GetLogViewerSavedSearchResponses, GetLogViewerValidateLogsSizeData, GetLogViewerValidateLogsSizeErrors, GetLogViewerValidateLogsSizeResponses, GetManifestManifestData, GetManifestManifestErrors, GetManifestManifestPrivateData, GetManifestManifestPrivateErrors, GetManifestManifestPrivateResponses, GetManifestManifestPublicData, GetManifestManifestPublicResponses, GetManifestManifestResponses, GetMediaAreReferencedData, GetMediaAreReferencedErrors, GetMediaAreReferencedResponses, GetMediaByIdAuditLogData, GetMediaByIdAuditLogErrors, GetMediaByIdAuditLogResponses, GetMediaByIdData, GetMediaByIdErrors, GetMediaByIdReferencedByData, GetMediaByIdReferencedByErrors, GetMediaByIdReferencedByResponses, GetMediaByIdReferencedDescendantsData, GetMediaByIdReferencedDescendantsErrors, GetMediaByIdReferencedDescendantsResponses, GetMediaByIdResponses, GetMediaConfigurationData, GetMediaConfigurationErrors, GetMediaConfigurationResponses, GetMediaTypeAllowedAtRootData, GetMediaTypeAllowedAtRootErrors, GetMediaTypeAllowedAtRootResponses, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenErrors, GetMediaTypeByIdAllowedChildrenResponses, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesErrors, GetMediaTypeByIdCompositionReferencesResponses, GetMediaTypeByIdData, GetMediaTypeByIdErrors, GetMediaTypeByIdExportData, GetMediaTypeByIdExportErrors, GetMediaTypeByIdExportResponses, GetMediaTypeByIdResponses, GetMediaTypeConfigurationData, GetMediaTypeConfigurationErrors, GetMediaTypeConfigurationResponses, GetMediaTypeFolderByIdData, GetMediaTypeFolderByIdErrors, GetMediaTypeFolderByIdResponses, GetMediaUrlsData, GetMediaUrlsErrors, GetMediaUrlsResponses, GetMemberAreReferencedData, GetMemberAreReferencedErrors, GetMemberAreReferencedResponses, GetMemberByIdData, GetMemberByIdErrors, GetMemberByIdReferencedByData, GetMemberByIdReferencedByErrors, GetMemberByIdReferencedByResponses, GetMemberByIdReferencedDescendantsData, GetMemberByIdReferencedDescendantsErrors, GetMemberByIdReferencedDescendantsResponses, GetMemberByIdResponses, GetMemberConfigurationData, GetMemberConfigurationErrors, GetMemberConfigurationResponses, GetMemberGroupByIdData, GetMemberGroupByIdErrors, GetMemberGroupByIdResponses, GetMemberGroupData, GetMemberGroupErrors, GetMemberGroupResponses, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesErrors, GetMemberTypeByIdCompositionReferencesResponses, GetMemberTypeByIdData, GetMemberTypeByIdErrors, GetMemberTypeByIdExportData, GetMemberTypeByIdExportErrors, GetMemberTypeByIdExportResponses, GetMemberTypeByIdResponses, GetMemberTypeConfigurationData, GetMemberTypeConfigurationErrors, GetMemberTypeConfigurationResponses, GetMemberTypeFolderByIdData, GetMemberTypeFolderByIdErrors, GetMemberTypeFolderByIdResponses, GetModelsBuilderDashboardData, GetModelsBuilderDashboardErrors, GetModelsBuilderDashboardResponses, GetModelsBuilderStatusData, GetModelsBuilderStatusErrors, GetModelsBuilderStatusResponses, GetNewsDashboardData, GetNewsDashboardErrors, GetNewsDashboardResponses, GetObjectTypesData, GetObjectTypesErrors, GetObjectTypesResponses, GetOembedQueryData, GetOembedQueryErrors, GetOembedQueryResponses, GetPackageConfigurationData, GetPackageConfigurationErrors, GetPackageConfigurationResponses, GetPackageCreatedByIdData, GetPackageCreatedByIdDownloadData, GetPackageCreatedByIdDownloadErrors, GetPackageCreatedByIdDownloadResponses, GetPackageCreatedByIdErrors, GetPackageCreatedByIdResponses, GetPackageCreatedData, GetPackageCreatedErrors, GetPackageCreatedResponses, GetPackageMigrationStatusData, GetPackageMigrationStatusErrors, GetPackageMigrationStatusResponses, GetPartialViewByPathData, GetPartialViewByPathErrors, GetPartialViewByPathResponses, GetPartialViewFolderByPathData, GetPartialViewFolderByPathErrors, GetPartialViewFolderByPathResponses, GetPartialViewSnippetByIdData, GetPartialViewSnippetByIdErrors, GetPartialViewSnippetByIdResponses, GetPartialViewSnippetData, GetPartialViewSnippetErrors, GetPartialViewSnippetResponses, GetProfilingStatusData, GetProfilingStatusErrors, GetProfilingStatusResponses, GetPropertyTypeIsUsedData, GetPropertyTypeIsUsedErrors, GetPropertyTypeIsUsedResponses, GetPublishedCacheRebuildStatusData, GetPublishedCacheRebuildStatusErrors, GetPublishedCacheRebuildStatusResponses, GetRecycleBinDocumentByIdOriginalParentData, GetRecycleBinDocumentByIdOriginalParentErrors, GetRecycleBinDocumentByIdOriginalParentResponses, GetRecycleBinDocumentChildrenData, GetRecycleBinDocumentChildrenErrors, GetRecycleBinDocumentChildrenResponses, GetRecycleBinDocumentReferencedByData, GetRecycleBinDocumentReferencedByErrors, GetRecycleBinDocumentReferencedByResponses, GetRecycleBinDocumentRootData, GetRecycleBinDocumentRootErrors, GetRecycleBinDocumentRootResponses, GetRecycleBinDocumentSiblingsData, GetRecycleBinDocumentSiblingsErrors, GetRecycleBinDocumentSiblingsResponses, GetRecycleBinMediaByIdOriginalParentData, GetRecycleBinMediaByIdOriginalParentErrors, GetRecycleBinMediaByIdOriginalParentResponses, GetRecycleBinMediaChildrenData, GetRecycleBinMediaChildrenErrors, GetRecycleBinMediaChildrenResponses, GetRecycleBinMediaReferencedByData, GetRecycleBinMediaReferencedByErrors, GetRecycleBinMediaReferencedByResponses, GetRecycleBinMediaRootData, GetRecycleBinMediaRootErrors, GetRecycleBinMediaRootResponses, GetRecycleBinMediaSiblingsData, GetRecycleBinMediaSiblingsErrors, GetRecycleBinMediaSiblingsResponses, GetRedirectManagementByIdData, GetRedirectManagementByIdErrors, GetRedirectManagementByIdResponses, GetRedirectManagementData, GetRedirectManagementErrors, GetRedirectManagementResponses, GetRedirectManagementStatusData, GetRedirectManagementStatusErrors, GetRedirectManagementStatusResponses, GetRelationByRelationTypeIdData, GetRelationByRelationTypeIdErrors, GetRelationByRelationTypeIdResponses, GetRelationTypeByIdData, GetRelationTypeByIdErrors, GetRelationTypeByIdResponses, GetRelationTypeData, GetRelationTypeErrors, GetRelationTypeResponses, GetScriptByPathData, GetScriptByPathErrors, GetScriptByPathResponses, GetScriptFolderByPathData, GetScriptFolderByPathErrors, GetScriptFolderByPathResponses, GetSearcherBySearcherNameQueryData, GetSearcherBySearcherNameQueryErrors, GetSearcherBySearcherNameQueryResponses, GetSearcherData, GetSearcherErrors, GetSearcherResponses, GetSecurityConfigurationData, GetSecurityConfigurationErrors, GetSecurityConfigurationResponses, GetSegmentData, GetSegmentErrors, GetSegmentResponses, GetServerConfigurationData, GetServerConfigurationResponses, GetServerInformationData, GetServerInformationErrors, GetServerInformationResponses, GetServerStatusData, GetServerStatusErrors, GetServerStatusResponses, GetServerTroubleshootingData, GetServerTroubleshootingErrors, GetServerTroubleshootingResponses, GetServerUpgradeCheckData, GetServerUpgradeCheckErrors, GetServerUpgradeCheckResponses, GetStylesheetByPathData, GetStylesheetByPathErrors, GetStylesheetByPathResponses, GetStylesheetFolderByPathData, GetStylesheetFolderByPathErrors, GetStylesheetFolderByPathResponses, GetTagData, GetTagErrors, GetTagResponses, GetTelemetryData, GetTelemetryErrors, GetTelemetryLevelData, GetTelemetryLevelErrors, GetTelemetryLevelResponses, GetTelemetryResponses, GetTemplateByIdData, GetTemplateByIdErrors, GetTemplateByIdResponses, GetTemplateConfigurationData, GetTemplateConfigurationErrors, GetTemplateConfigurationResponses, GetTemplateQuerySettingsData, GetTemplateQuerySettingsErrors, GetTemplateQuerySettingsResponses, GetTemporaryFileByIdData, GetTemporaryFileByIdErrors, GetTemporaryFileByIdResponses, GetTemporaryFileConfigurationData, GetTemporaryFileConfigurationErrors, GetTemporaryFileConfigurationResponses, GetTreeDataTypeAncestorsData, GetTreeDataTypeAncestorsErrors, GetTreeDataTypeAncestorsResponses, GetTreeDataTypeChildrenData, GetTreeDataTypeChildrenErrors, GetTreeDataTypeChildrenResponses, GetTreeDataTypeRootData, GetTreeDataTypeRootErrors, GetTreeDataTypeRootResponses, GetTreeDataTypeSiblingsData, GetTreeDataTypeSiblingsErrors, GetTreeDataTypeSiblingsResponses, GetTreeDictionaryAncestorsData, GetTreeDictionaryAncestorsErrors, GetTreeDictionaryAncestorsResponses, GetTreeDictionaryChildrenData, GetTreeDictionaryChildrenErrors, GetTreeDictionaryChildrenResponses, GetTreeDictionaryRootData, GetTreeDictionaryRootErrors, GetTreeDictionaryRootResponses, GetTreeDocumentAncestorsData, GetTreeDocumentAncestorsErrors, GetTreeDocumentAncestorsResponses, GetTreeDocumentBlueprintAncestorsData, GetTreeDocumentBlueprintAncestorsErrors, GetTreeDocumentBlueprintAncestorsResponses, GetTreeDocumentBlueprintChildrenData, GetTreeDocumentBlueprintChildrenErrors, GetTreeDocumentBlueprintChildrenResponses, GetTreeDocumentBlueprintRootData, GetTreeDocumentBlueprintRootErrors, GetTreeDocumentBlueprintRootResponses, GetTreeDocumentBlueprintSiblingsData, GetTreeDocumentBlueprintSiblingsErrors, GetTreeDocumentBlueprintSiblingsResponses, GetTreeDocumentChildrenData, GetTreeDocumentChildrenErrors, GetTreeDocumentChildrenResponses, GetTreeDocumentRootData, GetTreeDocumentRootErrors, GetTreeDocumentRootResponses, GetTreeDocumentSiblingsData, GetTreeDocumentSiblingsErrors, GetTreeDocumentSiblingsResponses, GetTreeDocumentTypeAncestorsData, GetTreeDocumentTypeAncestorsErrors, GetTreeDocumentTypeAncestorsResponses, GetTreeDocumentTypeChildrenData, GetTreeDocumentTypeChildrenErrors, GetTreeDocumentTypeChildrenResponses, GetTreeDocumentTypeRootData, GetTreeDocumentTypeRootErrors, GetTreeDocumentTypeRootResponses, GetTreeDocumentTypeSiblingsData, GetTreeDocumentTypeSiblingsErrors, GetTreeDocumentTypeSiblingsResponses, GetTreeMediaAncestorsData, GetTreeMediaAncestorsErrors, GetTreeMediaAncestorsResponses, GetTreeMediaChildrenData, GetTreeMediaChildrenErrors, GetTreeMediaChildrenResponses, GetTreeMediaRootData, GetTreeMediaRootErrors, GetTreeMediaRootResponses, GetTreeMediaSiblingsData, GetTreeMediaSiblingsErrors, GetTreeMediaSiblingsResponses, GetTreeMediaTypeAncestorsData, GetTreeMediaTypeAncestorsErrors, GetTreeMediaTypeAncestorsResponses, GetTreeMediaTypeChildrenData, GetTreeMediaTypeChildrenErrors, GetTreeMediaTypeChildrenResponses, GetTreeMediaTypeRootData, GetTreeMediaTypeRootErrors, GetTreeMediaTypeRootResponses, GetTreeMediaTypeSiblingsData, GetTreeMediaTypeSiblingsErrors, GetTreeMediaTypeSiblingsResponses, GetTreeMemberGroupRootData, GetTreeMemberGroupRootErrors, GetTreeMemberGroupRootResponses, GetTreeMemberTypeAncestorsData, GetTreeMemberTypeAncestorsErrors, GetTreeMemberTypeAncestorsResponses, GetTreeMemberTypeChildrenData, GetTreeMemberTypeChildrenErrors, GetTreeMemberTypeChildrenResponses, GetTreeMemberTypeRootData, GetTreeMemberTypeRootErrors, GetTreeMemberTypeRootResponses, GetTreeMemberTypeSiblingsData, GetTreeMemberTypeSiblingsErrors, GetTreeMemberTypeSiblingsResponses, GetTreePartialViewAncestorsData, GetTreePartialViewAncestorsErrors, GetTreePartialViewAncestorsResponses, GetTreePartialViewChildrenData, GetTreePartialViewChildrenErrors, GetTreePartialViewChildrenResponses, GetTreePartialViewRootData, GetTreePartialViewRootErrors, GetTreePartialViewRootResponses, GetTreePartialViewSiblingsData, GetTreePartialViewSiblingsErrors, GetTreePartialViewSiblingsResponses, GetTreeScriptAncestorsData, GetTreeScriptAncestorsErrors, GetTreeScriptAncestorsResponses, GetTreeScriptChildrenData, GetTreeScriptChildrenErrors, GetTreeScriptChildrenResponses, GetTreeScriptRootData, GetTreeScriptRootErrors, GetTreeScriptRootResponses, GetTreeScriptSiblingsData, GetTreeScriptSiblingsErrors, GetTreeScriptSiblingsResponses, GetTreeStaticFileAncestorsData, GetTreeStaticFileAncestorsErrors, GetTreeStaticFileAncestorsResponses, GetTreeStaticFileChildrenData, GetTreeStaticFileChildrenErrors, GetTreeStaticFileChildrenResponses, GetTreeStaticFileRootData, GetTreeStaticFileRootErrors, GetTreeStaticFileRootResponses, GetTreeStylesheetAncestorsData, GetTreeStylesheetAncestorsErrors, GetTreeStylesheetAncestorsResponses, GetTreeStylesheetChildrenData, GetTreeStylesheetChildrenErrors, GetTreeStylesheetChildrenResponses, GetTreeStylesheetRootData, GetTreeStylesheetRootErrors, GetTreeStylesheetRootResponses, GetTreeStylesheetSiblingsData, GetTreeStylesheetSiblingsErrors, GetTreeStylesheetSiblingsResponses, GetTreeTemplateAncestorsData, GetTreeTemplateAncestorsErrors, GetTreeTemplateAncestorsResponses, GetTreeTemplateChildrenData, GetTreeTemplateChildrenErrors, GetTreeTemplateChildrenResponses, GetTreeTemplateRootData, GetTreeTemplateRootErrors, GetTreeTemplateRootResponses, GetTreeTemplateSiblingsData, GetTreeTemplateSiblingsErrors, GetTreeTemplateSiblingsResponses, GetUpgradeSettingsData, GetUpgradeSettingsErrors, GetUpgradeSettingsResponses, GetUserById2FaData, GetUserById2FaErrors, GetUserById2FaResponses, GetUserByIdCalculateStartNodesData, GetUserByIdCalculateStartNodesErrors, GetUserByIdCalculateStartNodesResponses, GetUserByIdClientCredentialsData, GetUserByIdClientCredentialsErrors, GetUserByIdClientCredentialsResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, GetUserConfigurationData, GetUserConfigurationErrors, GetUserConfigurationResponses, GetUserCurrent2FaByProviderNameData, GetUserCurrent2FaByProviderNameErrors, GetUserCurrent2FaByProviderNameResponses, GetUserCurrent2FaData, GetUserCurrent2FaErrors, GetUserCurrent2FaResponses, GetUserCurrentConfigurationData, GetUserCurrentConfigurationErrors, GetUserCurrentConfigurationResponses, GetUserCurrentData, GetUserCurrentErrors, GetUserCurrentLoginProvidersData, GetUserCurrentLoginProvidersErrors, GetUserCurrentLoginProvidersResponses, GetUserCurrentPermissionsData, GetUserCurrentPermissionsDocumentData, GetUserCurrentPermissionsDocumentErrors, GetUserCurrentPermissionsDocumentResponses, GetUserCurrentPermissionsErrors, GetUserCurrentPermissionsMediaData, GetUserCurrentPermissionsMediaErrors, GetUserCurrentPermissionsMediaResponses, GetUserCurrentPermissionsResponses, GetUserCurrentResponses, GetUserData, GetUserDataByIdData, GetUserDataByIdErrors, GetUserDataByIdResponses, GetUserDataData, GetUserDataErrors, GetUserDataResponses, GetUserErrors, GetUserGroupByIdData, GetUserGroupByIdErrors, GetUserGroupByIdResponses, GetUserGroupData, GetUserGroupErrors, GetUserGroupResponses, GetUserResponses, GetWebhookByIdData, GetWebhookByIdErrors, GetWebhookByIdLogsData, GetWebhookByIdLogsErrors, GetWebhookByIdLogsResponses, GetWebhookByIdResponses, GetWebhookData, GetWebhookErrors, GetWebhookEventsData, GetWebhookEventsErrors, GetWebhookEventsResponses, GetWebhookLogsData, GetWebhookLogsErrors, GetWebhookLogsResponses, GetWebhookResponses, PostDataTypeByIdCopyData, PostDataTypeByIdCopyErrors, PostDataTypeByIdCopyResponses, PostDataTypeData, PostDataTypeErrors, PostDataTypeFolderData, PostDataTypeFolderErrors, PostDataTypeFolderResponses, PostDataTypeResponses, PostDictionaryData, PostDictionaryErrors, PostDictionaryImportData, PostDictionaryImportErrors, PostDictionaryImportResponses, PostDictionaryResponses, PostDocumentBlueprintData, PostDocumentBlueprintErrors, PostDocumentBlueprintFolderData, PostDocumentBlueprintFolderErrors, PostDocumentBlueprintFolderResponses, PostDocumentBlueprintFromDocumentData, PostDocumentBlueprintFromDocumentErrors, PostDocumentBlueprintFromDocumentResponses, PostDocumentBlueprintResponses, PostDocumentByIdCopyData, PostDocumentByIdCopyErrors, PostDocumentByIdCopyResponses, PostDocumentByIdPublicAccessData, PostDocumentByIdPublicAccessErrors, PostDocumentByIdPublicAccessResponses, PostDocumentData, PostDocumentErrors, PostDocumentResponses, PostDocumentTypeAvailableCompositionsData, PostDocumentTypeAvailableCompositionsErrors, PostDocumentTypeAvailableCompositionsResponses, PostDocumentTypeByIdCopyData, PostDocumentTypeByIdCopyErrors, PostDocumentTypeByIdCopyResponses, PostDocumentTypeByIdTemplateData, PostDocumentTypeByIdTemplateErrors, PostDocumentTypeByIdTemplateResponses, PostDocumentTypeData, PostDocumentTypeErrors, PostDocumentTypeFolderData, PostDocumentTypeFolderErrors, PostDocumentTypeFolderResponses, PostDocumentTypeImportData, PostDocumentTypeImportErrors, PostDocumentTypeImportResponses, PostDocumentTypeResponses, PostDocumentValidateData, PostDocumentValidateErrors, PostDocumentValidateResponses, PostDocumentVersionByIdRollbackData, PostDocumentVersionByIdRollbackErrors, PostDocumentVersionByIdRollbackResponses, PostDynamicRootQueryData, PostDynamicRootQueryErrors, PostDynamicRootQueryResponses, PostHealthCheckExecuteActionData, PostHealthCheckExecuteActionErrors, PostHealthCheckExecuteActionResponses, PostHealthCheckGroupByNameCheckData, PostHealthCheckGroupByNameCheckErrors, PostHealthCheckGroupByNameCheckResponses, PostIndexerByIndexNameRebuildData, PostIndexerByIndexNameRebuildErrors, PostIndexerByIndexNameRebuildResponses, PostInstallSetupData, PostInstallSetupErrors, PostInstallSetupResponses, PostInstallValidateDatabaseData, PostInstallValidateDatabaseErrors, PostInstallValidateDatabaseResponses, PostLanguageData, PostLanguageErrors, PostLanguageResponses, PostLogViewerSavedSearchData, PostLogViewerSavedSearchErrors, PostLogViewerSavedSearchResponses, PostMediaData, PostMediaErrors, PostMediaResponses, PostMediaTypeAvailableCompositionsData, PostMediaTypeAvailableCompositionsErrors, PostMediaTypeAvailableCompositionsResponses, PostMediaTypeByIdCopyData, PostMediaTypeByIdCopyErrors, PostMediaTypeByIdCopyResponses, PostMediaTypeData, PostMediaTypeErrors, PostMediaTypeFolderData, PostMediaTypeFolderErrors, PostMediaTypeFolderResponses, PostMediaTypeImportData, PostMediaTypeImportErrors, PostMediaTypeImportResponses, PostMediaTypeResponses, PostMediaValidateData, PostMediaValidateErrors, PostMediaValidateResponses, PostMemberData, PostMemberErrors, PostMemberGroupData, PostMemberGroupErrors, PostMemberGroupResponses, PostMemberResponses, PostMemberTypeAvailableCompositionsData, PostMemberTypeAvailableCompositionsErrors, PostMemberTypeAvailableCompositionsResponses, PostMemberTypeByIdCopyData, PostMemberTypeByIdCopyErrors, PostMemberTypeByIdCopyResponses, PostMemberTypeData, PostMemberTypeErrors, PostMemberTypeFolderData, PostMemberTypeFolderErrors, PostMemberTypeFolderResponses, PostMemberTypeImportData, PostMemberTypeImportErrors, PostMemberTypeImportResponses, PostMemberTypeResponses, PostMemberValidateData, PostMemberValidateErrors, PostMemberValidateResponses, PostModelsBuilderBuildData, PostModelsBuilderBuildErrors, PostModelsBuilderBuildResponses, PostPackageByNameRunMigrationData, PostPackageByNameRunMigrationErrors, PostPackageByNameRunMigrationResponses, PostPackageCreatedData, PostPackageCreatedErrors, PostPackageCreatedResponses, PostPartialViewData, PostPartialViewErrors, PostPartialViewFolderData, PostPartialViewFolderErrors, PostPartialViewFolderResponses, PostPartialViewResponses, PostPreviewData, PostPreviewErrors, PostPreviewResponses, PostPublishedCacheRebuildData, PostPublishedCacheRebuildErrors, PostPublishedCacheRebuildResponses, PostPublishedCacheReloadData, PostPublishedCacheReloadErrors, PostPublishedCacheReloadResponses, PostRedirectManagementStatusData, PostRedirectManagementStatusErrors, PostRedirectManagementStatusResponses, PostScriptData, PostScriptErrors, PostScriptFolderData, PostScriptFolderErrors, PostScriptFolderResponses, PostScriptResponses, PostSecurityForgotPasswordData, PostSecurityForgotPasswordErrors, PostSecurityForgotPasswordResetData, PostSecurityForgotPasswordResetErrors, PostSecurityForgotPasswordResetResponses, PostSecurityForgotPasswordResponses, PostSecurityForgotPasswordVerifyData, PostSecurityForgotPasswordVerifyErrors, PostSecurityForgotPasswordVerifyResponses, PostStylesheetData, PostStylesheetErrors, PostStylesheetFolderData, PostStylesheetFolderErrors, PostStylesheetFolderResponses, PostStylesheetResponses, PostTelemetryLevelData, PostTelemetryLevelErrors, PostTelemetryLevelResponses, PostTemplateData, PostTemplateErrors, PostTemplateQueryExecuteData, PostTemplateQueryExecuteErrors, PostTemplateQueryExecuteResponses, PostTemplateResponses, PostTemporaryFileData, PostTemporaryFileErrors, PostTemporaryFileResponses, PostUpgradeAuthorizeData, PostUpgradeAuthorizeErrors, PostUpgradeAuthorizeResponses, PostUserAvatarByIdData, PostUserAvatarByIdErrors, PostUserAvatarByIdResponses, PostUserByIdChangePasswordData, PostUserByIdChangePasswordErrors, PostUserByIdChangePasswordResponses, PostUserByIdClientCredentialsData, PostUserByIdClientCredentialsErrors, PostUserByIdClientCredentialsResponses, PostUserByIdResetPasswordData, PostUserByIdResetPasswordErrors, PostUserByIdResetPasswordResponses, PostUserCurrent2FaByProviderNameData, PostUserCurrent2FaByProviderNameErrors, PostUserCurrent2FaByProviderNameResponses, PostUserCurrentAvatarData, PostUserCurrentAvatarErrors, PostUserCurrentAvatarResponses, PostUserCurrentChangePasswordData, PostUserCurrentChangePasswordErrors, PostUserCurrentChangePasswordResponses, PostUserData, PostUserDataData, PostUserDataErrors, PostUserDataResponses, PostUserDisableData, PostUserDisableErrors, PostUserDisableResponses, PostUserEnableData, PostUserEnableErrors, PostUserEnableResponses, PostUserErrors, PostUserGroupByIdUsersData, PostUserGroupByIdUsersErrors, PostUserGroupByIdUsersResponses, PostUserGroupData, PostUserGroupErrors, PostUserGroupResponses, PostUserInviteCreatePasswordData, PostUserInviteCreatePasswordErrors, PostUserInviteCreatePasswordResponses, PostUserInviteData, PostUserInviteErrors, PostUserInviteResendData, PostUserInviteResendErrors, PostUserInviteResendResponses, PostUserInviteResponses, PostUserInviteVerifyData, PostUserInviteVerifyErrors, PostUserInviteVerifyResponses, PostUserResponses, PostUserSetUserGroupsData, PostUserSetUserGroupsErrors, PostUserSetUserGroupsResponses, PostUserUnlockData, PostUserUnlockErrors, PostUserUnlockResponses, PostWebhookData, PostWebhookErrors, PostWebhookResponses, PutDataTypeByIdData, PutDataTypeByIdErrors, PutDataTypeByIdMoveData, PutDataTypeByIdMoveErrors, PutDataTypeByIdMoveResponses, PutDataTypeByIdResponses, PutDataTypeFolderByIdData, PutDataTypeFolderByIdErrors, PutDataTypeFolderByIdResponses, PutDictionaryByIdData, PutDictionaryByIdErrors, PutDictionaryByIdMoveData, PutDictionaryByIdMoveErrors, PutDictionaryByIdMoveResponses, PutDictionaryByIdResponses, PutDocumentBlueprintByIdData, PutDocumentBlueprintByIdErrors, PutDocumentBlueprintByIdMoveData, PutDocumentBlueprintByIdMoveErrors, PutDocumentBlueprintByIdMoveResponses, PutDocumentBlueprintByIdResponses, PutDocumentBlueprintFolderByIdData, PutDocumentBlueprintFolderByIdErrors, PutDocumentBlueprintFolderByIdResponses, PutDocumentByIdData, PutDocumentByIdDomainsData, PutDocumentByIdDomainsErrors, PutDocumentByIdDomainsResponses, PutDocumentByIdErrors, PutDocumentByIdMoveData, PutDocumentByIdMoveErrors, PutDocumentByIdMoveResponses, PutDocumentByIdMoveToRecycleBinData, PutDocumentByIdMoveToRecycleBinErrors, PutDocumentByIdMoveToRecycleBinResponses, PutDocumentByIdNotificationsData, PutDocumentByIdNotificationsErrors, PutDocumentByIdNotificationsResponses, PutDocumentByIdPublicAccessData, PutDocumentByIdPublicAccessErrors, PutDocumentByIdPublicAccessResponses, PutDocumentByIdPublishData, PutDocumentByIdPublishErrors, PutDocumentByIdPublishResponses, PutDocumentByIdPublishWithDescendantsData, PutDocumentByIdPublishWithDescendantsErrors, PutDocumentByIdPublishWithDescendantsResponses, PutDocumentByIdResponses, PutDocumentByIdUnpublishData, PutDocumentByIdUnpublishErrors, PutDocumentByIdUnpublishResponses, PutDocumentSortData, PutDocumentSortErrors, PutDocumentSortResponses, PutDocumentTypeByIdData, PutDocumentTypeByIdErrors, PutDocumentTypeByIdImportData, PutDocumentTypeByIdImportErrors, PutDocumentTypeByIdImportResponses, PutDocumentTypeByIdMoveData, PutDocumentTypeByIdMoveErrors, PutDocumentTypeByIdMoveResponses, PutDocumentTypeByIdResponses, PutDocumentTypeFolderByIdData, PutDocumentTypeFolderByIdErrors, PutDocumentTypeFolderByIdResponses, PutDocumentVersionByIdPreventCleanupData, PutDocumentVersionByIdPreventCleanupErrors, PutDocumentVersionByIdPreventCleanupResponses, PutLanguageByIsoCodeData, PutLanguageByIsoCodeErrors, PutLanguageByIsoCodeResponses, PutMediaByIdData, PutMediaByIdErrors, PutMediaByIdMoveData, PutMediaByIdMoveErrors, PutMediaByIdMoveResponses, PutMediaByIdMoveToRecycleBinData, PutMediaByIdMoveToRecycleBinErrors, PutMediaByIdMoveToRecycleBinResponses, PutMediaByIdResponses, PutMediaByIdValidateData, PutMediaByIdValidateErrors, PutMediaByIdValidateResponses, PutMediaSortData, PutMediaSortErrors, PutMediaSortResponses, PutMediaTypeByIdData, PutMediaTypeByIdErrors, PutMediaTypeByIdImportData, PutMediaTypeByIdImportErrors, PutMediaTypeByIdImportResponses, PutMediaTypeByIdMoveData, PutMediaTypeByIdMoveErrors, PutMediaTypeByIdMoveResponses, PutMediaTypeByIdResponses, PutMediaTypeFolderByIdData, PutMediaTypeFolderByIdErrors, PutMediaTypeFolderByIdResponses, PutMemberByIdData, PutMemberByIdErrors, PutMemberByIdResponses, PutMemberByIdValidateData, PutMemberByIdValidateErrors, PutMemberByIdValidateResponses, PutMemberGroupByIdData, PutMemberGroupByIdErrors, PutMemberGroupByIdResponses, PutMemberTypeByIdData, PutMemberTypeByIdErrors, PutMemberTypeByIdImportData, PutMemberTypeByIdImportErrors, PutMemberTypeByIdImportResponses, PutMemberTypeByIdMoveData, PutMemberTypeByIdMoveErrors, PutMemberTypeByIdMoveResponses, PutMemberTypeByIdResponses, PutMemberTypeFolderByIdData, PutMemberTypeFolderByIdErrors, PutMemberTypeFolderByIdResponses, PutPackageCreatedByIdData, PutPackageCreatedByIdErrors, PutPackageCreatedByIdResponses, PutPartialViewByPathData, PutPartialViewByPathErrors, PutPartialViewByPathRenameData, PutPartialViewByPathRenameErrors, PutPartialViewByPathRenameResponses, PutPartialViewByPathResponses, PutProfilingStatusData, PutProfilingStatusErrors, PutProfilingStatusResponses, PutRecycleBinDocumentByIdRestoreData, PutRecycleBinDocumentByIdRestoreErrors, PutRecycleBinDocumentByIdRestoreResponses, PutRecycleBinMediaByIdRestoreData, PutRecycleBinMediaByIdRestoreErrors, PutRecycleBinMediaByIdRestoreResponses, PutScriptByPathData, PutScriptByPathErrors, PutScriptByPathRenameData, PutScriptByPathRenameErrors, PutScriptByPathRenameResponses, PutScriptByPathResponses, PutStylesheetByPathData, PutStylesheetByPathErrors, PutStylesheetByPathRenameData, PutStylesheetByPathRenameErrors, PutStylesheetByPathRenameResponses, PutStylesheetByPathResponses, PutTemplateByIdData, PutTemplateByIdErrors, PutTemplateByIdResponses, PutUmbracoManagementApiV11DocumentByIdValidate11Data, PutUmbracoManagementApiV11DocumentByIdValidate11Errors, PutUmbracoManagementApiV11DocumentByIdValidate11Responses, PutUserByIdData, PutUserByIdErrors, PutUserByIdResponses, PutUserDataData, PutUserDataErrors, PutUserDataResponses, PutUserGroupByIdData, PutUserGroupByIdErrors, PutUserGroupByIdResponses, PutWebhookByIdData, PutWebhookByIdErrors, PutWebhookByIdResponses } from './types.gen'; +import type { DeleteDataTypeByIdData, DeleteDataTypeByIdErrors, DeleteDataTypeByIdResponses, DeleteDataTypeFolderByIdData, DeleteDataTypeFolderByIdErrors, DeleteDataTypeFolderByIdResponses, DeleteDictionaryByIdData, DeleteDictionaryByIdErrors, DeleteDictionaryByIdResponses, DeleteDocumentBlueprintByIdData, DeleteDocumentBlueprintByIdErrors, DeleteDocumentBlueprintByIdResponses, DeleteDocumentBlueprintFolderByIdData, DeleteDocumentBlueprintFolderByIdErrors, DeleteDocumentBlueprintFolderByIdResponses, DeleteDocumentByIdData, DeleteDocumentByIdErrors, DeleteDocumentByIdPublicAccessData, DeleteDocumentByIdPublicAccessErrors, DeleteDocumentByIdPublicAccessResponses, DeleteDocumentByIdResponses, DeleteDocumentTypeByIdData, DeleteDocumentTypeByIdErrors, DeleteDocumentTypeByIdResponses, DeleteDocumentTypeFolderByIdData, DeleteDocumentTypeFolderByIdErrors, DeleteDocumentTypeFolderByIdResponses, DeleteElementByIdData, DeleteElementByIdErrors, DeleteElementByIdResponses, DeleteElementFolderByIdData, DeleteElementFolderByIdErrors, DeleteElementFolderByIdResponses, DeleteLanguageByIsoCodeData, DeleteLanguageByIsoCodeErrors, DeleteLanguageByIsoCodeResponses, DeleteLogViewerSavedSearchByNameData, DeleteLogViewerSavedSearchByNameErrors, DeleteLogViewerSavedSearchByNameResponses, DeleteMediaByIdData, DeleteMediaByIdErrors, DeleteMediaByIdResponses, DeleteMediaTypeByIdData, DeleteMediaTypeByIdErrors, DeleteMediaTypeByIdResponses, DeleteMediaTypeFolderByIdData, DeleteMediaTypeFolderByIdErrors, DeleteMediaTypeFolderByIdResponses, DeleteMemberByIdData, DeleteMemberByIdErrors, DeleteMemberByIdResponses, DeleteMemberGroupByIdData, DeleteMemberGroupByIdErrors, DeleteMemberGroupByIdResponses, DeleteMemberTypeByIdData, DeleteMemberTypeByIdErrors, DeleteMemberTypeByIdResponses, DeleteMemberTypeFolderByIdData, DeleteMemberTypeFolderByIdErrors, DeleteMemberTypeFolderByIdResponses, DeletePackageCreatedByIdData, DeletePackageCreatedByIdErrors, DeletePackageCreatedByIdResponses, DeletePartialViewByPathData, DeletePartialViewByPathErrors, DeletePartialViewByPathResponses, DeletePartialViewFolderByPathData, DeletePartialViewFolderByPathErrors, DeletePartialViewFolderByPathResponses, DeletePreviewData, DeletePreviewResponses, DeleteRecycleBinDocumentByIdData, DeleteRecycleBinDocumentByIdErrors, DeleteRecycleBinDocumentByIdResponses, DeleteRecycleBinDocumentData, DeleteRecycleBinDocumentErrors, DeleteRecycleBinDocumentResponses, DeleteRecycleBinElementByIdData, DeleteRecycleBinElementByIdErrors, DeleteRecycleBinElementByIdResponses, DeleteRecycleBinElementData, DeleteRecycleBinElementErrors, DeleteRecycleBinElementFolderByIdData, DeleteRecycleBinElementFolderByIdErrors, DeleteRecycleBinElementFolderByIdResponses, DeleteRecycleBinElementResponses, DeleteRecycleBinMediaByIdData, DeleteRecycleBinMediaByIdErrors, DeleteRecycleBinMediaByIdResponses, DeleteRecycleBinMediaData, DeleteRecycleBinMediaErrors, DeleteRecycleBinMediaResponses, DeleteRedirectManagementByIdData, DeleteRedirectManagementByIdErrors, DeleteRedirectManagementByIdResponses, DeleteScriptByPathData, DeleteScriptByPathErrors, DeleteScriptByPathResponses, DeleteScriptFolderByPathData, DeleteScriptFolderByPathErrors, DeleteScriptFolderByPathResponses, DeleteStylesheetByPathData, DeleteStylesheetByPathErrors, DeleteStylesheetByPathResponses, DeleteStylesheetFolderByPathData, DeleteStylesheetFolderByPathErrors, DeleteStylesheetFolderByPathResponses, DeleteTemplateByIdData, DeleteTemplateByIdErrors, DeleteTemplateByIdResponses, DeleteTemporaryFileByIdData, DeleteTemporaryFileByIdErrors, DeleteTemporaryFileByIdResponses, DeleteUserAvatarByIdData, DeleteUserAvatarByIdErrors, DeleteUserAvatarByIdResponses, DeleteUserById2FaByProviderNameData, DeleteUserById2FaByProviderNameErrors, DeleteUserById2FaByProviderNameResponses, DeleteUserByIdClientCredentialsByClientIdData, DeleteUserByIdClientCredentialsByClientIdErrors, DeleteUserByIdClientCredentialsByClientIdResponses, DeleteUserByIdData, DeleteUserByIdErrors, DeleteUserByIdResponses, DeleteUserCurrent2FaByProviderNameData, DeleteUserCurrent2FaByProviderNameErrors, DeleteUserCurrent2FaByProviderNameResponses, DeleteUserData, DeleteUserDataByIdData, DeleteUserDataByIdErrors, DeleteUserDataByIdResponses, DeleteUserErrors, DeleteUserGroupByIdData, DeleteUserGroupByIdErrors, DeleteUserGroupByIdResponses, DeleteUserGroupByIdUsersData, DeleteUserGroupByIdUsersErrors, DeleteUserGroupByIdUsersResponses, DeleteUserGroupData, DeleteUserGroupErrors, DeleteUserGroupResponses, DeleteUserResponses, DeleteWebhookByIdData, DeleteWebhookByIdErrors, DeleteWebhookByIdResponses, GetCollectionDocumentByIdData, GetCollectionDocumentByIdErrors, GetCollectionDocumentByIdResponses, GetCollectionMediaData, GetCollectionMediaErrors, GetCollectionMediaResponses, GetCultureData, GetCultureErrors, GetCultureResponses, GetDataTypeByIdData, GetDataTypeByIdErrors, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedErrors, GetDataTypeByIdIsUsedResponses, GetDataTypeByIdReferencedByData, GetDataTypeByIdReferencedByErrors, GetDataTypeByIdReferencedByResponses, GetDataTypeByIdResponses, GetDataTypeConfigurationData, GetDataTypeConfigurationErrors, GetDataTypeConfigurationResponses, GetDataTypeFolderByIdData, GetDataTypeFolderByIdErrors, GetDataTypeFolderByIdResponses, GetDictionaryByIdData, GetDictionaryByIdErrors, GetDictionaryByIdExportData, GetDictionaryByIdExportErrors, GetDictionaryByIdExportResponses, GetDictionaryByIdResponses, GetDictionaryData, GetDictionaryErrors, GetDictionaryResponses, GetDocumentAreReferencedData, GetDocumentAreReferencedErrors, GetDocumentAreReferencedResponses, GetDocumentBlueprintByIdData, GetDocumentBlueprintByIdErrors, GetDocumentBlueprintByIdResponses, GetDocumentBlueprintByIdScaffoldData, GetDocumentBlueprintByIdScaffoldErrors, GetDocumentBlueprintByIdScaffoldResponses, GetDocumentBlueprintFolderByIdData, GetDocumentBlueprintFolderByIdErrors, GetDocumentBlueprintFolderByIdResponses, GetDocumentByIdAuditLogData, GetDocumentByIdAuditLogErrors, GetDocumentByIdAuditLogResponses, GetDocumentByIdAvailableSegmentOptionsData, GetDocumentByIdAvailableSegmentOptionsErrors, GetDocumentByIdAvailableSegmentOptionsResponses, GetDocumentByIdData, GetDocumentByIdDomainsData, GetDocumentByIdDomainsErrors, GetDocumentByIdDomainsResponses, GetDocumentByIdErrors, GetDocumentByIdNotificationsData, GetDocumentByIdNotificationsErrors, GetDocumentByIdNotificationsResponses, GetDocumentByIdPreviewUrlData, GetDocumentByIdPreviewUrlErrors, GetDocumentByIdPreviewUrlResponses, GetDocumentByIdPublicAccessData, GetDocumentByIdPublicAccessErrors, GetDocumentByIdPublicAccessResponses, GetDocumentByIdPublishedData, GetDocumentByIdPublishedErrors, GetDocumentByIdPublishedResponses, GetDocumentByIdPublishWithDescendantsResultByTaskIdData, GetDocumentByIdPublishWithDescendantsResultByTaskIdErrors, GetDocumentByIdPublishWithDescendantsResultByTaskIdResponses, GetDocumentByIdReferencedByData, GetDocumentByIdReferencedByErrors, GetDocumentByIdReferencedByResponses, GetDocumentByIdReferencedDescendantsData, GetDocumentByIdReferencedDescendantsErrors, GetDocumentByIdReferencedDescendantsResponses, GetDocumentByIdResponses, GetDocumentConfigurationData, GetDocumentConfigurationErrors, GetDocumentConfigurationResponses, GetDocumentTypeAllowedAtRootData, GetDocumentTypeAllowedAtRootErrors, GetDocumentTypeAllowedAtRootResponses, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenErrors, GetDocumentTypeByIdAllowedChildrenResponses, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintErrors, GetDocumentTypeByIdBlueprintResponses, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesErrors, GetDocumentTypeByIdCompositionReferencesResponses, GetDocumentTypeByIdData, GetDocumentTypeByIdErrors, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportErrors, GetDocumentTypeByIdExportResponses, GetDocumentTypeByIdResponses, GetDocumentTypeConfigurationData, GetDocumentTypeConfigurationErrors, GetDocumentTypeConfigurationResponses, GetDocumentTypeFolderByIdData, GetDocumentTypeFolderByIdErrors, GetDocumentTypeFolderByIdResponses, GetDocumentUrlsData, GetDocumentUrlsErrors, GetDocumentUrlsResponses, GetDocumentVersionByIdData, GetDocumentVersionByIdErrors, GetDocumentVersionByIdResponses, GetDocumentVersionData, GetDocumentVersionErrors, GetDocumentVersionResponses, GetDynamicRootStepsData, GetDynamicRootStepsErrors, GetDynamicRootStepsResponses, GetElementByIdData, GetElementByIdErrors, GetElementByIdResponses, GetElementConfigurationData, GetElementConfigurationErrors, GetElementConfigurationResponses, GetElementFolderByIdData, GetElementFolderByIdErrors, GetElementFolderByIdResponses, GetElementVersionByIdData, GetElementVersionByIdErrors, GetElementVersionByIdResponses, GetElementVersionData, GetElementVersionErrors, GetElementVersionResponses, GetFilterDataTypeData, GetFilterDataTypeErrors, GetFilterDataTypeResponses, GetFilterMemberData, GetFilterMemberErrors, GetFilterMemberResponses, GetFilterUserData, GetFilterUserErrors, GetFilterUserGroupData, GetFilterUserGroupErrors, GetFilterUserGroupResponses, GetFilterUserResponses, GetHealthCheckGroupByNameData, GetHealthCheckGroupByNameErrors, GetHealthCheckGroupByNameResponses, GetHealthCheckGroupData, GetHealthCheckGroupErrors, GetHealthCheckGroupResponses, GetHelpData, GetHelpErrors, GetHelpResponses, GetImagingResizeUrlsData, GetImagingResizeUrlsErrors, GetImagingResizeUrlsResponses, GetImportAnalyzeData, GetImportAnalyzeErrors, GetImportAnalyzeResponses, GetIndexerByIndexNameData, GetIndexerByIndexNameErrors, GetIndexerByIndexNameResponses, GetIndexerData, GetIndexerErrors, GetIndexerResponses, GetInstallSettingsData, GetInstallSettingsErrors, GetInstallSettingsResponses, GetItemDataTypeData, GetItemDataTypeErrors, GetItemDataTypeResponses, GetItemDataTypeSearchData, GetItemDataTypeSearchErrors, GetItemDataTypeSearchResponses, GetItemDictionaryData, GetItemDictionaryErrors, GetItemDictionaryResponses, GetItemDocumentBlueprintData, GetItemDocumentBlueprintErrors, GetItemDocumentBlueprintResponses, GetItemDocumentData, GetItemDocumentErrors, GetItemDocumentResponses, GetItemDocumentSearchData, GetItemDocumentSearchErrors, GetItemDocumentSearchResponses, GetItemDocumentTypeData, GetItemDocumentTypeErrors, GetItemDocumentTypeResponses, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchErrors, GetItemDocumentTypeSearchResponses, GetItemElementData, GetItemElementErrors, GetItemElementFolderData, GetItemElementFolderErrors, GetItemElementFolderResponses, GetItemElementResponses, GetItemLanguageData, GetItemLanguageDefaultData, GetItemLanguageDefaultErrors, GetItemLanguageDefaultResponses, GetItemLanguageErrors, GetItemLanguageResponses, GetItemMediaData, GetItemMediaErrors, GetItemMediaResponses, GetItemMediaSearchData, GetItemMediaSearchErrors, GetItemMediaSearchResponses, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedErrors, GetItemMediaTypeAllowedResponses, GetItemMediaTypeData, GetItemMediaTypeErrors, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersErrors, GetItemMediaTypeFoldersResponses, GetItemMediaTypeResponses, GetItemMediaTypeSearchData, GetItemMediaTypeSearchErrors, GetItemMediaTypeSearchResponses, GetItemMemberData, GetItemMemberErrors, GetItemMemberGroupData, GetItemMemberGroupErrors, GetItemMemberGroupResponses, GetItemMemberResponses, GetItemMemberSearchData, GetItemMemberSearchErrors, GetItemMemberSearchResponses, GetItemMemberTypeData, GetItemMemberTypeErrors, GetItemMemberTypeResponses, GetItemMemberTypeSearchData, GetItemMemberTypeSearchErrors, GetItemMemberTypeSearchResponses, GetItemPartialViewData, GetItemPartialViewErrors, GetItemPartialViewResponses, GetItemRelationTypeData, GetItemRelationTypeErrors, GetItemRelationTypeResponses, GetItemScriptData, GetItemScriptErrors, GetItemScriptResponses, GetItemStaticFileData, GetItemStaticFileErrors, GetItemStaticFileResponses, GetItemStylesheetData, GetItemStylesheetErrors, GetItemStylesheetResponses, GetItemTemplateData, GetItemTemplateErrors, GetItemTemplateResponses, GetItemTemplateSearchData, GetItemTemplateSearchErrors, GetItemTemplateSearchResponses, GetItemUserData, GetItemUserErrors, GetItemUserGroupData, GetItemUserGroupErrors, GetItemUserGroupResponses, GetItemUserResponses, GetItemWebhookData, GetItemWebhookErrors, GetItemWebhookResponses, GetLanguageByIsoCodeData, GetLanguageByIsoCodeErrors, GetLanguageByIsoCodeResponses, GetLanguageData, GetLanguageErrors, GetLanguageResponses, GetLogViewerLevelCountData, GetLogViewerLevelCountErrors, GetLogViewerLevelCountResponses, GetLogViewerLevelData, GetLogViewerLevelErrors, GetLogViewerLevelResponses, GetLogViewerLogData, GetLogViewerLogErrors, GetLogViewerLogResponses, GetLogViewerMessageTemplateData, GetLogViewerMessageTemplateErrors, GetLogViewerMessageTemplateResponses, GetLogViewerSavedSearchByNameData, GetLogViewerSavedSearchByNameErrors, GetLogViewerSavedSearchByNameResponses, GetLogViewerSavedSearchData, GetLogViewerSavedSearchErrors, GetLogViewerSavedSearchResponses, GetLogViewerValidateLogsSizeData, GetLogViewerValidateLogsSizeErrors, GetLogViewerValidateLogsSizeResponses, GetManifestManifestData, GetManifestManifestErrors, GetManifestManifestPrivateData, GetManifestManifestPrivateErrors, GetManifestManifestPrivateResponses, GetManifestManifestPublicData, GetManifestManifestPublicResponses, GetManifestManifestResponses, GetMediaAreReferencedData, GetMediaAreReferencedErrors, GetMediaAreReferencedResponses, GetMediaByIdAuditLogData, GetMediaByIdAuditLogErrors, GetMediaByIdAuditLogResponses, GetMediaByIdData, GetMediaByIdErrors, GetMediaByIdReferencedByData, GetMediaByIdReferencedByErrors, GetMediaByIdReferencedByResponses, GetMediaByIdReferencedDescendantsData, GetMediaByIdReferencedDescendantsErrors, GetMediaByIdReferencedDescendantsResponses, GetMediaByIdResponses, GetMediaConfigurationData, GetMediaConfigurationErrors, GetMediaConfigurationResponses, GetMediaTypeAllowedAtRootData, GetMediaTypeAllowedAtRootErrors, GetMediaTypeAllowedAtRootResponses, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenErrors, GetMediaTypeByIdAllowedChildrenResponses, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesErrors, GetMediaTypeByIdCompositionReferencesResponses, GetMediaTypeByIdData, GetMediaTypeByIdErrors, GetMediaTypeByIdExportData, GetMediaTypeByIdExportErrors, GetMediaTypeByIdExportResponses, GetMediaTypeByIdResponses, GetMediaTypeConfigurationData, GetMediaTypeConfigurationErrors, GetMediaTypeConfigurationResponses, GetMediaTypeFolderByIdData, GetMediaTypeFolderByIdErrors, GetMediaTypeFolderByIdResponses, GetMediaUrlsData, GetMediaUrlsErrors, GetMediaUrlsResponses, GetMemberAreReferencedData, GetMemberAreReferencedErrors, GetMemberAreReferencedResponses, GetMemberByIdData, GetMemberByIdErrors, GetMemberByIdReferencedByData, GetMemberByIdReferencedByErrors, GetMemberByIdReferencedByResponses, GetMemberByIdReferencedDescendantsData, GetMemberByIdReferencedDescendantsErrors, GetMemberByIdReferencedDescendantsResponses, GetMemberByIdResponses, GetMemberConfigurationData, GetMemberConfigurationErrors, GetMemberConfigurationResponses, GetMemberGroupByIdData, GetMemberGroupByIdErrors, GetMemberGroupByIdResponses, GetMemberGroupData, GetMemberGroupErrors, GetMemberGroupResponses, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesErrors, GetMemberTypeByIdCompositionReferencesResponses, GetMemberTypeByIdData, GetMemberTypeByIdErrors, GetMemberTypeByIdExportData, GetMemberTypeByIdExportErrors, GetMemberTypeByIdExportResponses, GetMemberTypeByIdResponses, GetMemberTypeConfigurationData, GetMemberTypeConfigurationErrors, GetMemberTypeConfigurationResponses, GetMemberTypeFolderByIdData, GetMemberTypeFolderByIdErrors, GetMemberTypeFolderByIdResponses, GetModelsBuilderDashboardData, GetModelsBuilderDashboardErrors, GetModelsBuilderDashboardResponses, GetModelsBuilderStatusData, GetModelsBuilderStatusErrors, GetModelsBuilderStatusResponses, GetNewsDashboardData, GetNewsDashboardErrors, GetNewsDashboardResponses, GetObjectTypesData, GetObjectTypesErrors, GetObjectTypesResponses, GetOembedQueryData, GetOembedQueryErrors, GetOembedQueryResponses, GetPackageConfigurationData, GetPackageConfigurationErrors, GetPackageConfigurationResponses, GetPackageCreatedByIdData, GetPackageCreatedByIdDownloadData, GetPackageCreatedByIdDownloadErrors, GetPackageCreatedByIdDownloadResponses, GetPackageCreatedByIdErrors, GetPackageCreatedByIdResponses, GetPackageCreatedData, GetPackageCreatedErrors, GetPackageCreatedResponses, GetPackageMigrationStatusData, GetPackageMigrationStatusErrors, GetPackageMigrationStatusResponses, GetPartialViewByPathData, GetPartialViewByPathErrors, GetPartialViewByPathResponses, GetPartialViewFolderByPathData, GetPartialViewFolderByPathErrors, GetPartialViewFolderByPathResponses, GetPartialViewSnippetByIdData, GetPartialViewSnippetByIdErrors, GetPartialViewSnippetByIdResponses, GetPartialViewSnippetData, GetPartialViewSnippetErrors, GetPartialViewSnippetResponses, GetProfilingStatusData, GetProfilingStatusErrors, GetProfilingStatusResponses, GetPropertyTypeIsUsedData, GetPropertyTypeIsUsedErrors, GetPropertyTypeIsUsedResponses, GetPublishedCacheRebuildStatusData, GetPublishedCacheRebuildStatusErrors, GetPublishedCacheRebuildStatusResponses, GetRecycleBinDocumentByIdOriginalParentData, GetRecycleBinDocumentByIdOriginalParentErrors, GetRecycleBinDocumentByIdOriginalParentResponses, GetRecycleBinDocumentChildrenData, GetRecycleBinDocumentChildrenErrors, GetRecycleBinDocumentChildrenResponses, GetRecycleBinDocumentReferencedByData, GetRecycleBinDocumentReferencedByErrors, GetRecycleBinDocumentReferencedByResponses, GetRecycleBinDocumentRootData, GetRecycleBinDocumentRootErrors, GetRecycleBinDocumentRootResponses, GetRecycleBinDocumentSiblingsData, GetRecycleBinDocumentSiblingsErrors, GetRecycleBinDocumentSiblingsResponses, GetRecycleBinElementChildrenData, GetRecycleBinElementChildrenErrors, GetRecycleBinElementChildrenResponses, GetRecycleBinElementRootData, GetRecycleBinElementRootErrors, GetRecycleBinElementRootResponses, GetRecycleBinElementSiblingsData, GetRecycleBinElementSiblingsErrors, GetRecycleBinElementSiblingsResponses, GetRecycleBinMediaByIdOriginalParentData, GetRecycleBinMediaByIdOriginalParentErrors, GetRecycleBinMediaByIdOriginalParentResponses, GetRecycleBinMediaChildrenData, GetRecycleBinMediaChildrenErrors, GetRecycleBinMediaChildrenResponses, GetRecycleBinMediaReferencedByData, GetRecycleBinMediaReferencedByErrors, GetRecycleBinMediaReferencedByResponses, GetRecycleBinMediaRootData, GetRecycleBinMediaRootErrors, GetRecycleBinMediaRootResponses, GetRecycleBinMediaSiblingsData, GetRecycleBinMediaSiblingsErrors, GetRecycleBinMediaSiblingsResponses, GetRedirectManagementByIdData, GetRedirectManagementByIdErrors, GetRedirectManagementByIdResponses, GetRedirectManagementData, GetRedirectManagementErrors, GetRedirectManagementResponses, GetRedirectManagementStatusData, GetRedirectManagementStatusErrors, GetRedirectManagementStatusResponses, GetRelationByRelationTypeIdData, GetRelationByRelationTypeIdErrors, GetRelationByRelationTypeIdResponses, GetRelationTypeByIdData, GetRelationTypeByIdErrors, GetRelationTypeByIdResponses, GetRelationTypeData, GetRelationTypeErrors, GetRelationTypeResponses, GetScriptByPathData, GetScriptByPathErrors, GetScriptByPathResponses, GetScriptFolderByPathData, GetScriptFolderByPathErrors, GetScriptFolderByPathResponses, GetSearcherBySearcherNameQueryData, GetSearcherBySearcherNameQueryErrors, GetSearcherBySearcherNameQueryResponses, GetSearcherData, GetSearcherErrors, GetSearcherResponses, GetSecurityConfigurationData, GetSecurityConfigurationErrors, GetSecurityConfigurationResponses, GetSegmentData, GetSegmentErrors, GetSegmentResponses, GetServerConfigurationData, GetServerConfigurationResponses, GetServerInformationData, GetServerInformationErrors, GetServerInformationResponses, GetServerStatusData, GetServerStatusErrors, GetServerStatusResponses, GetServerTroubleshootingData, GetServerTroubleshootingErrors, GetServerTroubleshootingResponses, GetServerUpgradeCheckData, GetServerUpgradeCheckErrors, GetServerUpgradeCheckResponses, GetStylesheetByPathData, GetStylesheetByPathErrors, GetStylesheetByPathResponses, GetStylesheetFolderByPathData, GetStylesheetFolderByPathErrors, GetStylesheetFolderByPathResponses, GetTagData, GetTagErrors, GetTagResponses, GetTelemetryData, GetTelemetryErrors, GetTelemetryLevelData, GetTelemetryLevelErrors, GetTelemetryLevelResponses, GetTelemetryResponses, GetTemplateByIdData, GetTemplateByIdErrors, GetTemplateByIdResponses, GetTemplateConfigurationData, GetTemplateConfigurationErrors, GetTemplateConfigurationResponses, GetTemplateQuerySettingsData, GetTemplateQuerySettingsErrors, GetTemplateQuerySettingsResponses, GetTemporaryFileByIdData, GetTemporaryFileByIdErrors, GetTemporaryFileByIdResponses, GetTemporaryFileConfigurationData, GetTemporaryFileConfigurationErrors, GetTemporaryFileConfigurationResponses, GetTreeDataTypeAncestorsData, GetTreeDataTypeAncestorsErrors, GetTreeDataTypeAncestorsResponses, GetTreeDataTypeChildrenData, GetTreeDataTypeChildrenErrors, GetTreeDataTypeChildrenResponses, GetTreeDataTypeRootData, GetTreeDataTypeRootErrors, GetTreeDataTypeRootResponses, GetTreeDataTypeSiblingsData, GetTreeDataTypeSiblingsErrors, GetTreeDataTypeSiblingsResponses, GetTreeDictionaryAncestorsData, GetTreeDictionaryAncestorsErrors, GetTreeDictionaryAncestorsResponses, GetTreeDictionaryChildrenData, GetTreeDictionaryChildrenErrors, GetTreeDictionaryChildrenResponses, GetTreeDictionaryRootData, GetTreeDictionaryRootErrors, GetTreeDictionaryRootResponses, GetTreeDocumentAncestorsData, GetTreeDocumentAncestorsErrors, GetTreeDocumentAncestorsResponses, GetTreeDocumentBlueprintAncestorsData, GetTreeDocumentBlueprintAncestorsErrors, GetTreeDocumentBlueprintAncestorsResponses, GetTreeDocumentBlueprintChildrenData, GetTreeDocumentBlueprintChildrenErrors, GetTreeDocumentBlueprintChildrenResponses, GetTreeDocumentBlueprintRootData, GetTreeDocumentBlueprintRootErrors, GetTreeDocumentBlueprintRootResponses, GetTreeDocumentBlueprintSiblingsData, GetTreeDocumentBlueprintSiblingsErrors, GetTreeDocumentBlueprintSiblingsResponses, GetTreeDocumentChildrenData, GetTreeDocumentChildrenErrors, GetTreeDocumentChildrenResponses, GetTreeDocumentRootData, GetTreeDocumentRootErrors, GetTreeDocumentRootResponses, GetTreeDocumentSiblingsData, GetTreeDocumentSiblingsErrors, GetTreeDocumentSiblingsResponses, GetTreeDocumentTypeAncestorsData, GetTreeDocumentTypeAncestorsErrors, GetTreeDocumentTypeAncestorsResponses, GetTreeDocumentTypeChildrenData, GetTreeDocumentTypeChildrenErrors, GetTreeDocumentTypeChildrenResponses, GetTreeDocumentTypeRootData, GetTreeDocumentTypeRootErrors, GetTreeDocumentTypeRootResponses, GetTreeDocumentTypeSiblingsData, GetTreeDocumentTypeSiblingsErrors, GetTreeDocumentTypeSiblingsResponses, GetTreeElementAncestorsData, GetTreeElementAncestorsErrors, GetTreeElementAncestorsResponses, GetTreeElementChildrenData, GetTreeElementChildrenErrors, GetTreeElementChildrenResponses, GetTreeElementRootData, GetTreeElementRootErrors, GetTreeElementRootResponses, GetTreeElementSiblingsData, GetTreeElementSiblingsErrors, GetTreeElementSiblingsResponses, GetTreeMediaAncestorsData, GetTreeMediaAncestorsErrors, GetTreeMediaAncestorsResponses, GetTreeMediaChildrenData, GetTreeMediaChildrenErrors, GetTreeMediaChildrenResponses, GetTreeMediaRootData, GetTreeMediaRootErrors, GetTreeMediaRootResponses, GetTreeMediaSiblingsData, GetTreeMediaSiblingsErrors, GetTreeMediaSiblingsResponses, GetTreeMediaTypeAncestorsData, GetTreeMediaTypeAncestorsErrors, GetTreeMediaTypeAncestorsResponses, GetTreeMediaTypeChildrenData, GetTreeMediaTypeChildrenErrors, GetTreeMediaTypeChildrenResponses, GetTreeMediaTypeRootData, GetTreeMediaTypeRootErrors, GetTreeMediaTypeRootResponses, GetTreeMediaTypeSiblingsData, GetTreeMediaTypeSiblingsErrors, GetTreeMediaTypeSiblingsResponses, GetTreeMemberGroupRootData, GetTreeMemberGroupRootErrors, GetTreeMemberGroupRootResponses, GetTreeMemberTypeAncestorsData, GetTreeMemberTypeAncestorsErrors, GetTreeMemberTypeAncestorsResponses, GetTreeMemberTypeChildrenData, GetTreeMemberTypeChildrenErrors, GetTreeMemberTypeChildrenResponses, GetTreeMemberTypeRootData, GetTreeMemberTypeRootErrors, GetTreeMemberTypeRootResponses, GetTreeMemberTypeSiblingsData, GetTreeMemberTypeSiblingsErrors, GetTreeMemberTypeSiblingsResponses, GetTreePartialViewAncestorsData, GetTreePartialViewAncestorsErrors, GetTreePartialViewAncestorsResponses, GetTreePartialViewChildrenData, GetTreePartialViewChildrenErrors, GetTreePartialViewChildrenResponses, GetTreePartialViewRootData, GetTreePartialViewRootErrors, GetTreePartialViewRootResponses, GetTreePartialViewSiblingsData, GetTreePartialViewSiblingsErrors, GetTreePartialViewSiblingsResponses, GetTreeScriptAncestorsData, GetTreeScriptAncestorsErrors, GetTreeScriptAncestorsResponses, GetTreeScriptChildrenData, GetTreeScriptChildrenErrors, GetTreeScriptChildrenResponses, GetTreeScriptRootData, GetTreeScriptRootErrors, GetTreeScriptRootResponses, GetTreeScriptSiblingsData, GetTreeScriptSiblingsErrors, GetTreeScriptSiblingsResponses, GetTreeStaticFileAncestorsData, GetTreeStaticFileAncestorsErrors, GetTreeStaticFileAncestorsResponses, GetTreeStaticFileChildrenData, GetTreeStaticFileChildrenErrors, GetTreeStaticFileChildrenResponses, GetTreeStaticFileRootData, GetTreeStaticFileRootErrors, GetTreeStaticFileRootResponses, GetTreeStylesheetAncestorsData, GetTreeStylesheetAncestorsErrors, GetTreeStylesheetAncestorsResponses, GetTreeStylesheetChildrenData, GetTreeStylesheetChildrenErrors, GetTreeStylesheetChildrenResponses, GetTreeStylesheetRootData, GetTreeStylesheetRootErrors, GetTreeStylesheetRootResponses, GetTreeStylesheetSiblingsData, GetTreeStylesheetSiblingsErrors, GetTreeStylesheetSiblingsResponses, GetTreeTemplateAncestorsData, GetTreeTemplateAncestorsErrors, GetTreeTemplateAncestorsResponses, GetTreeTemplateChildrenData, GetTreeTemplateChildrenErrors, GetTreeTemplateChildrenResponses, GetTreeTemplateRootData, GetTreeTemplateRootErrors, GetTreeTemplateRootResponses, GetTreeTemplateSiblingsData, GetTreeTemplateSiblingsErrors, GetTreeTemplateSiblingsResponses, GetUpgradeSettingsData, GetUpgradeSettingsErrors, GetUpgradeSettingsResponses, GetUserById2FaData, GetUserById2FaErrors, GetUserById2FaResponses, GetUserByIdCalculateStartNodesData, GetUserByIdCalculateStartNodesErrors, GetUserByIdCalculateStartNodesResponses, GetUserByIdClientCredentialsData, GetUserByIdClientCredentialsErrors, GetUserByIdClientCredentialsResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, GetUserConfigurationData, GetUserConfigurationErrors, GetUserConfigurationResponses, GetUserCurrent2FaByProviderNameData, GetUserCurrent2FaByProviderNameErrors, GetUserCurrent2FaByProviderNameResponses, GetUserCurrent2FaData, GetUserCurrent2FaErrors, GetUserCurrent2FaResponses, GetUserCurrentConfigurationData, GetUserCurrentConfigurationErrors, GetUserCurrentConfigurationResponses, GetUserCurrentData, GetUserCurrentErrors, GetUserCurrentLoginProvidersData, GetUserCurrentLoginProvidersErrors, GetUserCurrentLoginProvidersResponses, GetUserCurrentPermissionsData, GetUserCurrentPermissionsDocumentData, GetUserCurrentPermissionsDocumentErrors, GetUserCurrentPermissionsDocumentResponses, GetUserCurrentPermissionsElementData, GetUserCurrentPermissionsElementErrors, GetUserCurrentPermissionsElementResponses, GetUserCurrentPermissionsErrors, GetUserCurrentPermissionsMediaData, GetUserCurrentPermissionsMediaErrors, GetUserCurrentPermissionsMediaResponses, GetUserCurrentPermissionsResponses, GetUserCurrentResponses, GetUserData, GetUserDataByIdData, GetUserDataByIdErrors, GetUserDataByIdResponses, GetUserDataData, GetUserDataErrors, GetUserDataResponses, GetUserErrors, GetUserGroupByIdData, GetUserGroupByIdErrors, GetUserGroupByIdResponses, GetUserGroupData, GetUserGroupErrors, GetUserGroupResponses, GetUserResponses, GetWebhookByIdData, GetWebhookByIdErrors, GetWebhookByIdLogsData, GetWebhookByIdLogsErrors, GetWebhookByIdLogsResponses, GetWebhookByIdResponses, GetWebhookData, GetWebhookErrors, GetWebhookEventsData, GetWebhookEventsErrors, GetWebhookEventsResponses, GetWebhookLogsData, GetWebhookLogsErrors, GetWebhookLogsResponses, GetWebhookResponses, PostDataTypeByIdCopyData, PostDataTypeByIdCopyErrors, PostDataTypeByIdCopyResponses, PostDataTypeData, PostDataTypeErrors, PostDataTypeFolderData, PostDataTypeFolderErrors, PostDataTypeFolderResponses, PostDataTypeResponses, PostDictionaryData, PostDictionaryErrors, PostDictionaryImportData, PostDictionaryImportErrors, PostDictionaryImportResponses, PostDictionaryResponses, PostDocumentBlueprintData, PostDocumentBlueprintErrors, PostDocumentBlueprintFolderData, PostDocumentBlueprintFolderErrors, PostDocumentBlueprintFolderResponses, PostDocumentBlueprintFromDocumentData, PostDocumentBlueprintFromDocumentErrors, PostDocumentBlueprintFromDocumentResponses, PostDocumentBlueprintResponses, PostDocumentByIdCopyData, PostDocumentByIdCopyErrors, PostDocumentByIdCopyResponses, PostDocumentByIdPublicAccessData, PostDocumentByIdPublicAccessErrors, PostDocumentByIdPublicAccessResponses, PostDocumentData, PostDocumentErrors, PostDocumentResponses, PostDocumentTypeAvailableCompositionsData, PostDocumentTypeAvailableCompositionsErrors, PostDocumentTypeAvailableCompositionsResponses, PostDocumentTypeByIdCopyData, PostDocumentTypeByIdCopyErrors, PostDocumentTypeByIdCopyResponses, PostDocumentTypeByIdTemplateData, PostDocumentTypeByIdTemplateErrors, PostDocumentTypeByIdTemplateResponses, PostDocumentTypeData, PostDocumentTypeErrors, PostDocumentTypeFolderData, PostDocumentTypeFolderErrors, PostDocumentTypeFolderResponses, PostDocumentTypeImportData, PostDocumentTypeImportErrors, PostDocumentTypeImportResponses, PostDocumentTypeResponses, PostDocumentValidateData, PostDocumentValidateErrors, PostDocumentValidateResponses, PostDocumentVersionByIdRollbackData, PostDocumentVersionByIdRollbackErrors, PostDocumentVersionByIdRollbackResponses, PostDynamicRootQueryData, PostDynamicRootQueryErrors, PostDynamicRootQueryResponses, PostElementByIdCopyData, PostElementByIdCopyErrors, PostElementByIdCopyResponses, PostElementData, PostElementErrors, PostElementFolderData, PostElementFolderErrors, PostElementFolderResponses, PostElementResponses, PostElementValidateData, PostElementValidateErrors, PostElementValidateResponses, PostElementVersionByIdRollbackData, PostElementVersionByIdRollbackErrors, PostElementVersionByIdRollbackResponses, PostHealthCheckExecuteActionData, PostHealthCheckExecuteActionErrors, PostHealthCheckExecuteActionResponses, PostHealthCheckGroupByNameCheckData, PostHealthCheckGroupByNameCheckErrors, PostHealthCheckGroupByNameCheckResponses, PostIndexerByIndexNameRebuildData, PostIndexerByIndexNameRebuildErrors, PostIndexerByIndexNameRebuildResponses, PostInstallSetupData, PostInstallSetupErrors, PostInstallSetupResponses, PostInstallValidateDatabaseData, PostInstallValidateDatabaseErrors, PostInstallValidateDatabaseResponses, PostLanguageData, PostLanguageErrors, PostLanguageResponses, PostLogViewerSavedSearchData, PostLogViewerSavedSearchErrors, PostLogViewerSavedSearchResponses, PostMediaData, PostMediaErrors, PostMediaResponses, PostMediaTypeAvailableCompositionsData, PostMediaTypeAvailableCompositionsErrors, PostMediaTypeAvailableCompositionsResponses, PostMediaTypeByIdCopyData, PostMediaTypeByIdCopyErrors, PostMediaTypeByIdCopyResponses, PostMediaTypeData, PostMediaTypeErrors, PostMediaTypeFolderData, PostMediaTypeFolderErrors, PostMediaTypeFolderResponses, PostMediaTypeImportData, PostMediaTypeImportErrors, PostMediaTypeImportResponses, PostMediaTypeResponses, PostMediaValidateData, PostMediaValidateErrors, PostMediaValidateResponses, PostMemberData, PostMemberErrors, PostMemberGroupData, PostMemberGroupErrors, PostMemberGroupResponses, PostMemberResponses, PostMemberTypeAvailableCompositionsData, PostMemberTypeAvailableCompositionsErrors, PostMemberTypeAvailableCompositionsResponses, PostMemberTypeByIdCopyData, PostMemberTypeByIdCopyErrors, PostMemberTypeByIdCopyResponses, PostMemberTypeData, PostMemberTypeErrors, PostMemberTypeFolderData, PostMemberTypeFolderErrors, PostMemberTypeFolderResponses, PostMemberTypeImportData, PostMemberTypeImportErrors, PostMemberTypeImportResponses, PostMemberTypeResponses, PostMemberValidateData, PostMemberValidateErrors, PostMemberValidateResponses, PostModelsBuilderBuildData, PostModelsBuilderBuildErrors, PostModelsBuilderBuildResponses, PostPackageByNameRunMigrationData, PostPackageByNameRunMigrationErrors, PostPackageByNameRunMigrationResponses, PostPackageCreatedData, PostPackageCreatedErrors, PostPackageCreatedResponses, PostPartialViewData, PostPartialViewErrors, PostPartialViewFolderData, PostPartialViewFolderErrors, PostPartialViewFolderResponses, PostPartialViewResponses, PostPreviewData, PostPreviewErrors, PostPreviewResponses, PostPublishedCacheRebuildData, PostPublishedCacheRebuildErrors, PostPublishedCacheRebuildResponses, PostPublishedCacheReloadData, PostPublishedCacheReloadErrors, PostPublishedCacheReloadResponses, PostRedirectManagementStatusData, PostRedirectManagementStatusErrors, PostRedirectManagementStatusResponses, PostScriptData, PostScriptErrors, PostScriptFolderData, PostScriptFolderErrors, PostScriptFolderResponses, PostScriptResponses, PostSecurityForgotPasswordData, PostSecurityForgotPasswordErrors, PostSecurityForgotPasswordResetData, PostSecurityForgotPasswordResetErrors, PostSecurityForgotPasswordResetResponses, PostSecurityForgotPasswordResponses, PostSecurityForgotPasswordVerifyData, PostSecurityForgotPasswordVerifyErrors, PostSecurityForgotPasswordVerifyResponses, PostStylesheetData, PostStylesheetErrors, PostStylesheetFolderData, PostStylesheetFolderErrors, PostStylesheetFolderResponses, PostStylesheetResponses, PostTelemetryLevelData, PostTelemetryLevelErrors, PostTelemetryLevelResponses, PostTemplateData, PostTemplateErrors, PostTemplateQueryExecuteData, PostTemplateQueryExecuteErrors, PostTemplateQueryExecuteResponses, PostTemplateResponses, PostTemporaryFileData, PostTemporaryFileErrors, PostTemporaryFileResponses, PostUpgradeAuthorizeData, PostUpgradeAuthorizeErrors, PostUpgradeAuthorizeResponses, PostUserAvatarByIdData, PostUserAvatarByIdErrors, PostUserAvatarByIdResponses, PostUserByIdChangePasswordData, PostUserByIdChangePasswordErrors, PostUserByIdChangePasswordResponses, PostUserByIdClientCredentialsData, PostUserByIdClientCredentialsErrors, PostUserByIdClientCredentialsResponses, PostUserByIdResetPasswordData, PostUserByIdResetPasswordErrors, PostUserByIdResetPasswordResponses, PostUserCurrent2FaByProviderNameData, PostUserCurrent2FaByProviderNameErrors, PostUserCurrent2FaByProviderNameResponses, PostUserCurrentAvatarData, PostUserCurrentAvatarErrors, PostUserCurrentAvatarResponses, PostUserCurrentChangePasswordData, PostUserCurrentChangePasswordErrors, PostUserCurrentChangePasswordResponses, PostUserData, PostUserDataData, PostUserDataErrors, PostUserDataResponses, PostUserDisableData, PostUserDisableErrors, PostUserDisableResponses, PostUserEnableData, PostUserEnableErrors, PostUserEnableResponses, PostUserErrors, PostUserGroupByIdUsersData, PostUserGroupByIdUsersErrors, PostUserGroupByIdUsersResponses, PostUserGroupData, PostUserGroupErrors, PostUserGroupResponses, PostUserInviteCreatePasswordData, PostUserInviteCreatePasswordErrors, PostUserInviteCreatePasswordResponses, PostUserInviteData, PostUserInviteErrors, PostUserInviteResendData, PostUserInviteResendErrors, PostUserInviteResendResponses, PostUserInviteResponses, PostUserInviteVerifyData, PostUserInviteVerifyErrors, PostUserInviteVerifyResponses, PostUserResponses, PostUserSetUserGroupsData, PostUserSetUserGroupsErrors, PostUserSetUserGroupsResponses, PostUserUnlockData, PostUserUnlockErrors, PostUserUnlockResponses, PostWebhookData, PostWebhookErrors, PostWebhookResponses, PutDataTypeByIdData, PutDataTypeByIdErrors, PutDataTypeByIdMoveData, PutDataTypeByIdMoveErrors, PutDataTypeByIdMoveResponses, PutDataTypeByIdResponses, PutDataTypeFolderByIdData, PutDataTypeFolderByIdErrors, PutDataTypeFolderByIdResponses, PutDictionaryByIdData, PutDictionaryByIdErrors, PutDictionaryByIdMoveData, PutDictionaryByIdMoveErrors, PutDictionaryByIdMoveResponses, PutDictionaryByIdResponses, PutDocumentBlueprintByIdData, PutDocumentBlueprintByIdErrors, PutDocumentBlueprintByIdMoveData, PutDocumentBlueprintByIdMoveErrors, PutDocumentBlueprintByIdMoveResponses, PutDocumentBlueprintByIdResponses, PutDocumentBlueprintFolderByIdData, PutDocumentBlueprintFolderByIdErrors, PutDocumentBlueprintFolderByIdResponses, PutDocumentByIdData, PutDocumentByIdDomainsData, PutDocumentByIdDomainsErrors, PutDocumentByIdDomainsResponses, PutDocumentByIdErrors, PutDocumentByIdMoveData, PutDocumentByIdMoveErrors, PutDocumentByIdMoveResponses, PutDocumentByIdMoveToRecycleBinData, PutDocumentByIdMoveToRecycleBinErrors, PutDocumentByIdMoveToRecycleBinResponses, PutDocumentByIdNotificationsData, PutDocumentByIdNotificationsErrors, PutDocumentByIdNotificationsResponses, PutDocumentByIdPublicAccessData, PutDocumentByIdPublicAccessErrors, PutDocumentByIdPublicAccessResponses, PutDocumentByIdPublishData, PutDocumentByIdPublishErrors, PutDocumentByIdPublishResponses, PutDocumentByIdPublishWithDescendantsData, PutDocumentByIdPublishWithDescendantsErrors, PutDocumentByIdPublishWithDescendantsResponses, PutDocumentByIdResponses, PutDocumentByIdUnpublishData, PutDocumentByIdUnpublishErrors, PutDocumentByIdUnpublishResponses, PutDocumentSortData, PutDocumentSortErrors, PutDocumentSortResponses, PutDocumentTypeByIdData, PutDocumentTypeByIdErrors, PutDocumentTypeByIdImportData, PutDocumentTypeByIdImportErrors, PutDocumentTypeByIdImportResponses, PutDocumentTypeByIdMoveData, PutDocumentTypeByIdMoveErrors, PutDocumentTypeByIdMoveResponses, PutDocumentTypeByIdResponses, PutDocumentTypeFolderByIdData, PutDocumentTypeFolderByIdErrors, PutDocumentTypeFolderByIdResponses, PutDocumentVersionByIdPreventCleanupData, PutDocumentVersionByIdPreventCleanupErrors, PutDocumentVersionByIdPreventCleanupResponses, PutElementByIdData, PutElementByIdErrors, PutElementByIdMoveData, PutElementByIdMoveErrors, PutElementByIdMoveResponses, PutElementByIdMoveToRecycleBinData, PutElementByIdMoveToRecycleBinErrors, PutElementByIdMoveToRecycleBinResponses, PutElementByIdPublishData, PutElementByIdPublishErrors, PutElementByIdPublishResponses, PutElementByIdResponses, PutElementByIdUnpublishData, PutElementByIdUnpublishErrors, PutElementByIdUnpublishResponses, PutElementByIdValidateData, PutElementByIdValidateErrors, PutElementByIdValidateResponses, PutElementFolderByIdData, PutElementFolderByIdErrors, PutElementFolderByIdMoveData, PutElementFolderByIdMoveErrors, PutElementFolderByIdMoveResponses, PutElementFolderByIdMoveToRecycleBinData, PutElementFolderByIdMoveToRecycleBinErrors, PutElementFolderByIdMoveToRecycleBinResponses, PutElementFolderByIdResponses, PutElementVersionByIdPreventCleanupData, PutElementVersionByIdPreventCleanupErrors, PutElementVersionByIdPreventCleanupResponses, PutLanguageByIsoCodeData, PutLanguageByIsoCodeErrors, PutLanguageByIsoCodeResponses, PutMediaByIdData, PutMediaByIdErrors, PutMediaByIdMoveData, PutMediaByIdMoveErrors, PutMediaByIdMoveResponses, PutMediaByIdMoveToRecycleBinData, PutMediaByIdMoveToRecycleBinErrors, PutMediaByIdMoveToRecycleBinResponses, PutMediaByIdResponses, PutMediaByIdValidateData, PutMediaByIdValidateErrors, PutMediaByIdValidateResponses, PutMediaSortData, PutMediaSortErrors, PutMediaSortResponses, PutMediaTypeByIdData, PutMediaTypeByIdErrors, PutMediaTypeByIdImportData, PutMediaTypeByIdImportErrors, PutMediaTypeByIdImportResponses, PutMediaTypeByIdMoveData, PutMediaTypeByIdMoveErrors, PutMediaTypeByIdMoveResponses, PutMediaTypeByIdResponses, PutMediaTypeFolderByIdData, PutMediaTypeFolderByIdErrors, PutMediaTypeFolderByIdResponses, PutMemberByIdData, PutMemberByIdErrors, PutMemberByIdResponses, PutMemberByIdValidateData, PutMemberByIdValidateErrors, PutMemberByIdValidateResponses, PutMemberGroupByIdData, PutMemberGroupByIdErrors, PutMemberGroupByIdResponses, PutMemberTypeByIdData, PutMemberTypeByIdErrors, PutMemberTypeByIdImportData, PutMemberTypeByIdImportErrors, PutMemberTypeByIdImportResponses, PutMemberTypeByIdMoveData, PutMemberTypeByIdMoveErrors, PutMemberTypeByIdMoveResponses, PutMemberTypeByIdResponses, PutMemberTypeFolderByIdData, PutMemberTypeFolderByIdErrors, PutMemberTypeFolderByIdResponses, PutPackageCreatedByIdData, PutPackageCreatedByIdErrors, PutPackageCreatedByIdResponses, PutPartialViewByPathData, PutPartialViewByPathErrors, PutPartialViewByPathRenameData, PutPartialViewByPathRenameErrors, PutPartialViewByPathRenameResponses, PutPartialViewByPathResponses, PutProfilingStatusData, PutProfilingStatusErrors, PutProfilingStatusResponses, PutRecycleBinDocumentByIdRestoreData, PutRecycleBinDocumentByIdRestoreErrors, PutRecycleBinDocumentByIdRestoreResponses, PutRecycleBinMediaByIdRestoreData, PutRecycleBinMediaByIdRestoreErrors, PutRecycleBinMediaByIdRestoreResponses, PutScriptByPathData, PutScriptByPathErrors, PutScriptByPathRenameData, PutScriptByPathRenameErrors, PutScriptByPathRenameResponses, PutScriptByPathResponses, PutStylesheetByPathData, PutStylesheetByPathErrors, PutStylesheetByPathRenameData, PutStylesheetByPathRenameErrors, PutStylesheetByPathRenameResponses, PutStylesheetByPathResponses, PutTemplateByIdData, PutTemplateByIdErrors, PutTemplateByIdResponses, PutUmbracoManagementApiV11DocumentByIdValidate11Data, PutUmbracoManagementApiV11DocumentByIdValidate11Errors, PutUmbracoManagementApiV11DocumentByIdValidate11Responses, PutUserByIdData, PutUserByIdErrors, PutUserByIdResponses, PutUserDataData, PutUserDataErrors, PutUserDataResponses, PutUserGroupByIdData, PutUserGroupByIdErrors, PutUserGroupByIdResponses, PutWebhookByIdData, PutWebhookByIdErrors, PutWebhookByIdResponses } from './types.gen'; export type Options = Options2 & { /** @@ -1863,6 +1863,496 @@ export class DynamicRootService { } } +export class ElementVersionService { + public static getElementVersion(options: Options) { + return (options.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element-version', + ...options + }); + } + + public static getElementVersionById(options: Options) { + return (options.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element-version/{id}', + ...options + }); + } + + public static putElementVersionByIdPreventCleanup(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element-version/{id}/prevent-cleanup', + ...options + }); + } + + public static postElementVersionByIdRollback(options: Options) { + return (options.client ?? client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element-version/{id}/rollback', + ...options + }); + } +} + +export class ElementService { + public static postElement(options?: Options) { + return (options?.client ?? client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + public static deleteElementById(options: Options) { + return (options.client ?? client).delete({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/{id}', + ...options + }); + } + + public static getElementById(options: Options) { + return (options.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/{id}', + ...options + }); + } + + public static putElementById(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + public static postElementByIdCopy(options: Options) { + return (options.client ?? client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/{id}/copy', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + public static putElementByIdMove(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/{id}/move', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + public static putElementByIdMoveToRecycleBin(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/{id}/move-to-recycle-bin', + ...options + }); + } + + public static putElementByIdPublish(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/{id}/publish', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + public static putElementByIdUnpublish(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/{id}/unpublish', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + public static putElementByIdValidate(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/{id}/validate', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + public static getElementConfiguration(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/configuration', + ...options + }); + } + + public static postElementFolder(options?: Options) { + return (options?.client ?? client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/folder', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + public static deleteElementFolderById(options: Options) { + return (options.client ?? client).delete({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/folder/{id}', + ...options + }); + } + + public static getElementFolderById(options: Options) { + return (options.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/folder/{id}', + ...options + }); + } + + public static putElementFolderById(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/folder/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + public static putElementFolderByIdMove(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/folder/{id}/move', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + public static putElementFolderByIdMoveToRecycleBin(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/folder/{id}/move-to-recycle-bin', + ...options + }); + } + + public static postElementValidate(options?: Options) { + return (options?.client ?? client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/element/validate', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + public static getItemElement(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/item/element', + ...options + }); + } + + public static getItemElementFolder(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/item/element/folder', + ...options + }); + } + + public static deleteRecycleBinElement(options?: Options) { + return (options?.client ?? client).delete({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/recycle-bin/element', + ...options + }); + } + + public static deleteRecycleBinElementById(options: Options) { + return (options.client ?? client).delete({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/recycle-bin/element/{id}', + ...options + }); + } + + public static getRecycleBinElementChildren(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/recycle-bin/element/children', + ...options + }); + } + + public static deleteRecycleBinElementFolderById(options: Options) { + return (options.client ?? client).delete({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/recycle-bin/element/folder/{id}', + ...options + }); + } + + public static getRecycleBinElementRoot(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/recycle-bin/element/root', + ...options + }); + } + + public static getRecycleBinElementSiblings(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/recycle-bin/element/siblings', + ...options + }); + } + + public static getTreeElementAncestors(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/tree/element/ancestors', + ...options + }); + } + + public static getTreeElementChildren(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/tree/element/children', + ...options + }); + } + + public static getTreeElementRoot(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/tree/element/root', + ...options + }); + } + + public static getTreeElementSiblings(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/tree/element/siblings', + ...options + }); + } +} + export class HealthCheckService { public static getHealthCheckGroup(options?: Options) { return (options?.client ?? client).get({ @@ -5946,6 +6436,19 @@ export class UserService { }); } + public static getUserCurrentPermissionsElement(options?: Options) { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/user/current/permissions/element', + ...options + }); + } + public static getUserCurrentPermissionsMedia(options?: Options) { return (options?.client ?? client).get({ security: [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts index 8c068b393515..da6504154609 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts @@ -83,6 +83,8 @@ export type CalculatedUserStartNodesResponseModel = { hasDocumentRootAccess: boolean; mediaStartNodeIds: Array; hasMediaRootAccess: boolean; + elementStartNodeIds: Array; + hasElementRootAccess: boolean; }; export type ChangePasswordCurrentUserRequestModel = { @@ -118,6 +120,10 @@ export type CopyDocumentTypeRequestModel = { target?: ReferenceByIdModel | null; }; +export type CopyElementRequestModel = { + target?: ReferenceByIdModel | null; +}; + export type CopyMediaTypeRequestModel = { target?: ReferenceByIdModel | null; }; @@ -215,6 +221,14 @@ export type CreateDocumentTypeTemplateRequestModel = { isDefault: boolean; }; +export type CreateElementRequestModel = { + values: Array; + variants: Array; + id?: string | null; + parent?: ReferenceByIdModel | null; + documentType: ReferenceByIdModel; +}; + export type CreateFolderRequestModel = { name: string; id?: string | null; @@ -273,6 +287,7 @@ export type CreateMediaTypeRequestModel = { allowedAsRoot: boolean; variesByCulture: boolean; variesBySegment: boolean; + collection?: ReferenceByIdModel | null; isElement: boolean; properties: Array; containers: Array; @@ -280,7 +295,6 @@ export type CreateMediaTypeRequestModel = { parent?: ReferenceByIdModel | null; allowedMediaTypes: Array; compositions: Array; - collection?: ReferenceByIdModel | null; }; export type CreateMemberGroupRequestModel = { @@ -414,6 +428,7 @@ export type CreateUserDataRequestModel = { export type CreateUserGroupRequestModel = { name: string; alias: string; + description?: string | null; icon?: string | null; sections: Array; languages: Array; @@ -422,10 +437,11 @@ export type CreateUserGroupRequestModel = { documentRootAccess: boolean; mediaStartNode?: ReferenceByIdModel | null; mediaRootAccess: boolean; + elementStartNode?: ReferenceByIdModel | null; + elementRootAccess: boolean; fallbackPermissions: Array; - permissions: Array; + permissions: Array; id?: string | null; - description?: string | null; }; export type CreateUserRequestModel = { @@ -478,12 +494,14 @@ export type CurrentUserResponseModel = { hasDocumentRootAccess: boolean; mediaStartNodeIds: Array; hasMediaRootAccess: boolean; + elementStartNodeIds: Array; + hasElementRootAccess: boolean; avatarUrls: Array; languages: Array; hasAccessToAllLanguages: boolean; hasAccessToSensitiveData: boolean; fallbackPermissions: Array; - permissions: Array; + permissions: Array; allowedSections: Array; isAdmin: boolean; }; @@ -525,6 +543,7 @@ export type DataTypeTreeItemResponseModel = { flags: Array; name: string; isFolder: boolean; + noAccess: boolean; editorUiAlias?: string | null; isDeletable: boolean; }; @@ -633,6 +652,7 @@ export type DocumentBlueprintTreeItemResponseModel = { flags: Array; name: string; isFolder: boolean; + noAccess: boolean; documentType?: DocumentTypeReferenceResponseModel | null; }; @@ -853,6 +873,7 @@ export type DocumentTypeTreeItemResponseModel = { flags: Array; name: string; isFolder: boolean; + noAccess: boolean; isElement: boolean; icon: string; }; @@ -981,6 +1002,123 @@ export type DynamicRootResponseModel = { roots: Array; }; +export type ElementConfigurationResponseModel = { + disableDeleteWhenReferenced: boolean; + disableUnpublishWhenReferenced: boolean; + allowEditInvariantFromNonDefault: boolean; + /** + * @deprecated + */ + allowNonExistingSegmentsCreation: boolean; +}; + +export type ElementItemResponseModel = { + id: string; + flags: Array; + parent?: ReferenceByIdModel | null; + hasChildren: boolean; + documentType: DocumentTypeReferenceResponseModel; + variants: Array; +}; + +export type ElementPermissionPresentationModel = { + $type: string; + element: ReferenceByIdModel; + verbs: Array; +}; + +export type ElementRecycleBinItemResponseModel = { + id: string; + createDate: string; + hasChildren: boolean; + parent?: ItemReferenceByIdResponseModel | null; + documentType?: DocumentTypeReferenceResponseModel | null; + variants: Array; + isFolder: boolean; + name: string; +}; + +export type ElementResponseModel = { + values: Array; + variants: Array; + id: string; + flags: Array; + documentType: DocumentTypeReferenceResponseModel; + isTrashed: boolean; +}; + +export type ElementTreeItemResponseModel = { + hasChildren: boolean; + id: string; + parent?: ReferenceByIdModel | null; + flags: Array; + name: string; + isFolder: boolean; + noAccess: boolean; + createDate: string; + documentType?: DocumentTypeReferenceResponseModel | null; + variants: Array; +}; + +export type ElementValueModel = { + culture?: string | null; + segment?: string | null; + alias: string; + value?: unknown; +}; + +export type ElementValueResponseModel = { + culture?: string | null; + segment?: string | null; + alias: string; + value?: unknown; + editorAlias: string; +}; + +export type ElementVariantItemResponseModel = { + name: string; + culture?: string | null; + state: DocumentVariantStateModel; +}; + +export type ElementVariantRequestModel = { + culture?: string | null; + segment?: string | null; + name: string; +}; + +export type ElementVariantResponseModel = { + culture?: string | null; + segment?: string | null; + name: string; + createDate: string; + updateDate: string; + state: DocumentVariantStateModel; + publishDate?: string | null; + scheduledPublishDate?: string | null; + scheduledUnpublishDate?: string | null; +}; + +export type ElementVersionItemResponseModel = { + id: string; + element: ReferenceByIdModel; + documentType: ReferenceByIdModel; + user: ReferenceByIdModel; + versionDate: string; + isCurrentPublishedVersion: boolean; + isCurrentDraftVersion: boolean; + preventCleanup: boolean; +}; + +export type ElementVersionResponseModel = { + values: Array; + variants: Array; + id: string; + flags: Array; + documentType: DocumentTypeReferenceResponseModel; + element?: ReferenceByIdModel | null; +}; + export type EnableTwoFactorRequestModel = { code: string; secret: string; @@ -1025,9 +1163,16 @@ export type FlagModel = { alias: string; }; +export type FolderItemResponseModel = { + id: string; + flags: Array; + name: string; +}; + export type FolderResponseModel = { name: string; id: string; + isTrashed: boolean; }; export type HealthCheckActionRequestModel = { @@ -1386,6 +1531,7 @@ export type MediaTypeTreeItemResponseModel = { flags: Array; name: string; isFolder: boolean; + noAccess: boolean; icon: string; isDeletable: boolean; }; @@ -1577,6 +1723,7 @@ export type MemberTypeTreeItemResponseModel = { flags: Array; name: string; isFolder: boolean; + noAccess: boolean; icon: string; }; @@ -1639,6 +1786,14 @@ export type MoveDocumentTypeRequestModel = { target?: ReferenceByIdModel | null; }; +export type MoveElementRequestModel = { + target?: ReferenceByIdModel | null; +}; + +export type MoveFolderRequestModel = { + target?: ReferenceByIdModel | null; +}; + export type MoveMediaRequestModel = { target?: ReferenceByIdModel | null; }; @@ -1811,6 +1966,21 @@ export type PagedDocumentVersionItemResponseModel = { items: Array; }; +export type PagedElementRecycleBinItemResponseModel = { + total: number; + items: Array; +}; + +export type PagedElementTreeItemResponseModel = { + total: number; + items: Array; +}; + +export type PagedElementVersionItemResponseModel = { + total: number; + items: Array; +}; + export type PagedFileSystemTreeItemPresentationModel = { total: number; items: Array; @@ -2135,6 +2305,10 @@ export type PublishDocumentWithDescendantsRequestModel = { cultures: Array; }; +export type PublishElementRequestModel = { + publishSchedules: Array; +}; + export type PublishWithDescendantsResultModel = { taskId: string; isComplete: boolean; @@ -2413,6 +2587,18 @@ export type SubsetDocumentTypeTreeItemResponseModel = { items: Array; }; +export type SubsetElementRecycleBinItemResponseModel = { + totalBefore: number; + totalAfter: number; + items: Array; +}; + +export type SubsetElementTreeItemResponseModel = { + totalBefore: number; + totalAfter: number; + items: Array; +}; + export type SubsetFileSystemTreeItemPresentationModel = { totalBefore: number; totalAfter: number; @@ -2590,6 +2776,10 @@ export type UnpublishDocumentRequestModel = { cultures?: Array | null; }; +export type UnpublishElementRequestModel = { + cultures?: Array | null; +}; + export type UpdateDataTypeRequestModel = { name: string; editorAlias: string; @@ -2663,6 +2853,11 @@ export type UpdateDomainsRequestModel = { domains: Array; }; +export type UpdateElementRequestModel = { + values: Array; + variants: Array; +}; + export type UpdateFolderResponseModel = { name: string; }; @@ -2818,8 +3013,8 @@ export type UpdateUserDataRequestModel = { export type UpdateUserGroupRequestModel = { name: string; - description?: string | null; alias: string; + description?: string | null; icon?: string | null; sections: Array; languages: Array; @@ -2828,8 +3023,10 @@ export type UpdateUserGroupRequestModel = { documentRootAccess: boolean; mediaStartNode?: ReferenceByIdModel | null; mediaRootAccess: boolean; + elementStartNode?: ReferenceByIdModel | null; + elementRootAccess: boolean; fallbackPermissions: Array; - permissions: Array; + permissions: Array; }; export type UpdateUserGroupsOnUserRequestModel = { @@ -2847,6 +3044,8 @@ export type UpdateUserRequestModel = { hasDocumentRootAccess: boolean; mediaStartNodeIds: Array; hasMediaRootAccess: boolean; + elementStartNodeIds: Array; + hasElementRootAccess: boolean; }; export type UpdateWebhookRequestModel = { @@ -2921,6 +3120,7 @@ export type UserGroupItemResponseModel = { export type UserGroupResponseModel = { name: string; alias: string; + description?: string | null; icon?: string | null; sections: Array; languages: Array; @@ -2929,12 +3129,13 @@ export type UserGroupResponseModel = { documentRootAccess: boolean; mediaStartNode?: ReferenceByIdModel | null; mediaRootAccess: boolean; + elementStartNode?: ReferenceByIdModel | null; + elementRootAccess: boolean; fallbackPermissions: Array; - permissions: Array; + permissions: Array; id: string; isDeletable: boolean; aliasCanBeChanged: boolean; - description?: string | null; }; export type UserInstallRequestModel = { @@ -2990,6 +3191,8 @@ export type UserResponseModel = { hasDocumentRootAccess: boolean; mediaStartNodeIds: Array; hasMediaRootAccess: boolean; + elementStartNodeIds: Array; + hasElementRootAccess: boolean; avatarUrls: Array; state: UserStateModel; failedLoginAttempts: number; @@ -3029,6 +3232,12 @@ export type ValidateUpdateDocumentRequestModel = { cultures?: Array | null; }; +export type ValidateUpdateElementRequestModel = { + values: Array; + variants: Array; + cultures?: Array | null; +}; + export type VariantItemResponseModel = { name: string; culture?: string | null; @@ -6691,8 +6900,14 @@ export type GetDocumentByIdReferencedByErrors = { * The authenticated user does not have access to this resource */ 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; }; +export type GetDocumentByIdReferencedByError = GetDocumentByIdReferencedByErrors[keyof GetDocumentByIdReferencedByErrors]; + export type GetDocumentByIdReferencedByResponses = { /** * OK @@ -6723,8 +6938,14 @@ export type GetDocumentByIdReferencedDescendantsErrors = { * The authenticated user does not have access to this resource */ 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; }; +export type GetDocumentByIdReferencedDescendantsError = GetDocumentByIdReferencedDescendantsErrors[keyof GetDocumentByIdReferencedDescendantsErrors]; + export type GetDocumentByIdReferencedDescendantsResponses = { /** * OK @@ -7041,28 +7262,1114 @@ export type DeleteRecycleBinDocumentErrors = { /** * The authenticated user does not have access to this resource */ - 403: unknown; + 403: unknown; +}; + +export type DeleteRecycleBinDocumentError = DeleteRecycleBinDocumentErrors[keyof DeleteRecycleBinDocumentErrors]; + +export type DeleteRecycleBinDocumentResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type DeleteRecycleBinDocumentByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/recycle-bin/document/{id}'; +}; + +export type DeleteRecycleBinDocumentByIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type DeleteRecycleBinDocumentByIdError = DeleteRecycleBinDocumentByIdErrors[keyof DeleteRecycleBinDocumentByIdErrors]; + +export type DeleteRecycleBinDocumentByIdResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type GetRecycleBinDocumentByIdOriginalParentData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/recycle-bin/document/{id}/original-parent'; +}; + +export type GetRecycleBinDocumentByIdOriginalParentErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type GetRecycleBinDocumentByIdOriginalParentError = GetRecycleBinDocumentByIdOriginalParentErrors[keyof GetRecycleBinDocumentByIdOriginalParentErrors]; + +export type GetRecycleBinDocumentByIdOriginalParentResponses = { + /** + * OK + */ + 200: ReferenceByIdModel; +}; + +export type GetRecycleBinDocumentByIdOriginalParentResponse = GetRecycleBinDocumentByIdOriginalParentResponses[keyof GetRecycleBinDocumentByIdOriginalParentResponses]; + +export type PutRecycleBinDocumentByIdRestoreData = { + body?: MoveMediaRequestModel; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/recycle-bin/document/{id}/restore'; +}; + +export type PutRecycleBinDocumentByIdRestoreErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PutRecycleBinDocumentByIdRestoreError = PutRecycleBinDocumentByIdRestoreErrors[keyof PutRecycleBinDocumentByIdRestoreErrors]; + +export type PutRecycleBinDocumentByIdRestoreResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type GetRecycleBinDocumentChildrenData = { + body?: never; + path?: never; + query?: { + parentId?: string; + skip?: number; + take?: number; + }; + url: '/umbraco/management/api/v1/recycle-bin/document/children'; +}; + +export type GetRecycleBinDocumentChildrenErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type GetRecycleBinDocumentChildrenResponses = { + /** + * OK + */ + 200: PagedDocumentRecycleBinItemResponseModel; +}; + +export type GetRecycleBinDocumentChildrenResponse = GetRecycleBinDocumentChildrenResponses[keyof GetRecycleBinDocumentChildrenResponses]; + +export type GetRecycleBinDocumentReferencedByData = { + body?: never; + path?: never; + query?: { + skip?: number; + take?: number; + }; + url: '/umbraco/management/api/v1/recycle-bin/document/referenced-by'; +}; + +export type GetRecycleBinDocumentReferencedByErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type GetRecycleBinDocumentReferencedByResponses = { + /** + * OK + */ + 200: PagedIReferenceResponseModel; +}; + +export type GetRecycleBinDocumentReferencedByResponse = GetRecycleBinDocumentReferencedByResponses[keyof GetRecycleBinDocumentReferencedByResponses]; + +export type GetRecycleBinDocumentRootData = { + body?: never; + path?: never; + query?: { + skip?: number; + take?: number; + }; + url: '/umbraco/management/api/v1/recycle-bin/document/root'; +}; + +export type GetRecycleBinDocumentRootErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type GetRecycleBinDocumentRootResponses = { + /** + * OK + */ + 200: PagedDocumentRecycleBinItemResponseModel; +}; + +export type GetRecycleBinDocumentRootResponse = GetRecycleBinDocumentRootResponses[keyof GetRecycleBinDocumentRootResponses]; + +export type GetRecycleBinDocumentSiblingsData = { + body?: never; + path?: never; + query?: { + target?: string; + before?: number; + after?: number; + dataTypeId?: string; + }; + url: '/umbraco/management/api/v1/recycle-bin/document/siblings'; +}; + +export type GetRecycleBinDocumentSiblingsErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type GetRecycleBinDocumentSiblingsResponses = { + /** + * OK + */ + 200: SubsetDocumentRecycleBinItemResponseModel; +}; + +export type GetRecycleBinDocumentSiblingsResponse = GetRecycleBinDocumentSiblingsResponses[keyof GetRecycleBinDocumentSiblingsResponses]; + +export type GetTreeDocumentAncestorsData = { + body?: never; + path?: never; + query?: { + descendantId?: string; + }; + url: '/umbraco/management/api/v1/tree/document/ancestors'; +}; + +export type GetTreeDocumentAncestorsErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type GetTreeDocumentAncestorsResponses = { + /** + * OK + */ + 200: Array; +}; + +export type GetTreeDocumentAncestorsResponse = GetTreeDocumentAncestorsResponses[keyof GetTreeDocumentAncestorsResponses]; + +export type GetTreeDocumentChildrenData = { + body?: never; + path?: never; + query?: { + parentId?: string; + skip?: number; + take?: number; + dataTypeId?: string; + }; + url: '/umbraco/management/api/v1/tree/document/children'; +}; + +export type GetTreeDocumentChildrenErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type GetTreeDocumentChildrenResponses = { + /** + * OK + */ + 200: PagedDocumentTreeItemResponseModel; +}; + +export type GetTreeDocumentChildrenResponse = GetTreeDocumentChildrenResponses[keyof GetTreeDocumentChildrenResponses]; + +export type GetTreeDocumentRootData = { + body?: never; + path?: never; + query?: { + skip?: number; + take?: number; + dataTypeId?: string; + }; + url: '/umbraco/management/api/v1/tree/document/root'; +}; + +export type GetTreeDocumentRootErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type GetTreeDocumentRootResponses = { + /** + * OK + */ + 200: PagedDocumentTreeItemResponseModel; +}; + +export type GetTreeDocumentRootResponse = GetTreeDocumentRootResponses[keyof GetTreeDocumentRootResponses]; + +export type GetTreeDocumentSiblingsData = { + body?: never; + path?: never; + query?: { + target?: string; + before?: number; + after?: number; + dataTypeId?: string; + }; + url: '/umbraco/management/api/v1/tree/document/siblings'; +}; + +export type GetTreeDocumentSiblingsErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type GetTreeDocumentSiblingsResponses = { + /** + * OK + */ + 200: SubsetDocumentTreeItemResponseModel; +}; + +export type GetTreeDocumentSiblingsResponse = GetTreeDocumentSiblingsResponses[keyof GetTreeDocumentSiblingsResponses]; + +export type PostDynamicRootQueryData = { + body?: DynamicRootRequestModel; + path?: never; + query?: never; + url: '/umbraco/management/api/v1/dynamic-root/query'; +}; + +export type PostDynamicRootQueryErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type PostDynamicRootQueryResponses = { + /** + * OK + */ + 200: DynamicRootResponseModel; +}; + +export type PostDynamicRootQueryResponse = PostDynamicRootQueryResponses[keyof PostDynamicRootQueryResponses]; + +export type GetDynamicRootStepsData = { + body?: never; + path?: never; + query?: never; + url: '/umbraco/management/api/v1/dynamic-root/steps'; +}; + +export type GetDynamicRootStepsErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type GetDynamicRootStepsResponses = { + /** + * OK + */ + 200: Array; +}; + +export type GetDynamicRootStepsResponse = GetDynamicRootStepsResponses[keyof GetDynamicRootStepsResponses]; + +export type GetElementVersionData = { + body?: never; + path?: never; + query: { + elementId: string; + culture?: string; + skip?: number; + take?: number; + }; + url: '/umbraco/management/api/v1/element-version'; +}; + +export type GetElementVersionErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type GetElementVersionError = GetElementVersionErrors[keyof GetElementVersionErrors]; + +export type GetElementVersionResponses = { + /** + * OK + */ + 200: PagedElementVersionItemResponseModel; +}; + +export type GetElementVersionResponse = GetElementVersionResponses[keyof GetElementVersionResponses]; + +export type GetElementVersionByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element-version/{id}'; +}; + +export type GetElementVersionByIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type GetElementVersionByIdError = GetElementVersionByIdErrors[keyof GetElementVersionByIdErrors]; + +export type GetElementVersionByIdResponses = { + /** + * OK + */ + 200: ElementVersionResponseModel; +}; + +export type GetElementVersionByIdResponse = GetElementVersionByIdResponses[keyof GetElementVersionByIdResponses]; + +export type PutElementVersionByIdPreventCleanupData = { + body?: never; + path: { + id: string; + }; + query?: { + preventCleanup?: boolean; + }; + url: '/umbraco/management/api/v1/element-version/{id}/prevent-cleanup'; +}; + +export type PutElementVersionByIdPreventCleanupErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PutElementVersionByIdPreventCleanupError = PutElementVersionByIdPreventCleanupErrors[keyof PutElementVersionByIdPreventCleanupErrors]; + +export type PutElementVersionByIdPreventCleanupResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PostElementVersionByIdRollbackData = { + body?: never; + path: { + id: string; + }; + query?: { + culture?: string; + }; + url: '/umbraco/management/api/v1/element-version/{id}/rollback'; +}; + +export type PostElementVersionByIdRollbackErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PostElementVersionByIdRollbackError = PostElementVersionByIdRollbackErrors[keyof PostElementVersionByIdRollbackErrors]; + +export type PostElementVersionByIdRollbackResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PostElementData = { + body?: CreateElementRequestModel; + path?: never; + query?: never; + url: '/umbraco/management/api/v1/element'; +}; + +export type PostElementErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PostElementError = PostElementErrors[keyof PostElementErrors]; + +export type PostElementResponses = { + /** + * Created + */ + 201: unknown; +}; + +export type DeleteElementByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/{id}'; +}; + +export type DeleteElementByIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type DeleteElementByIdError = DeleteElementByIdErrors[keyof DeleteElementByIdErrors]; + +export type DeleteElementByIdResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type GetElementByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/{id}'; +}; + +export type GetElementByIdErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type GetElementByIdError = GetElementByIdErrors[keyof GetElementByIdErrors]; + +export type GetElementByIdResponses = { + /** + * OK + */ + 200: ElementResponseModel; +}; + +export type GetElementByIdResponse = GetElementByIdResponses[keyof GetElementByIdResponses]; + +export type PutElementByIdData = { + body?: UpdateElementRequestModel; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/{id}'; +}; + +export type PutElementByIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PutElementByIdError = PutElementByIdErrors[keyof PutElementByIdErrors]; + +export type PutElementByIdResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PostElementByIdCopyData = { + body?: CopyElementRequestModel; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/{id}/copy'; +}; + +export type PostElementByIdCopyErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PostElementByIdCopyError = PostElementByIdCopyErrors[keyof PostElementByIdCopyErrors]; + +export type PostElementByIdCopyResponses = { + /** + * Created + */ + 201: unknown; +}; + +export type PutElementByIdMoveData = { + body?: MoveElementRequestModel; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/{id}/move'; +}; + +export type PutElementByIdMoveErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PutElementByIdMoveError = PutElementByIdMoveErrors[keyof PutElementByIdMoveErrors]; + +export type PutElementByIdMoveResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PutElementByIdMoveToRecycleBinData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/{id}/move-to-recycle-bin'; +}; + +export type PutElementByIdMoveToRecycleBinErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PutElementByIdMoveToRecycleBinError = PutElementByIdMoveToRecycleBinErrors[keyof PutElementByIdMoveToRecycleBinErrors]; + +export type PutElementByIdMoveToRecycleBinResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PutElementByIdPublishData = { + body?: PublishElementRequestModel; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/{id}/publish'; +}; + +export type PutElementByIdPublishErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PutElementByIdPublishError = PutElementByIdPublishErrors[keyof PutElementByIdPublishErrors]; + +export type PutElementByIdPublishResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PutElementByIdUnpublishData = { + body?: UnpublishElementRequestModel; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/{id}/unpublish'; +}; + +export type PutElementByIdUnpublishErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PutElementByIdUnpublishError = PutElementByIdUnpublishErrors[keyof PutElementByIdUnpublishErrors]; + +export type PutElementByIdUnpublishResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PutElementByIdValidateData = { + body?: ValidateUpdateElementRequestModel; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/{id}/validate'; +}; + +export type PutElementByIdValidateErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PutElementByIdValidateError = PutElementByIdValidateErrors[keyof PutElementByIdValidateErrors]; + +export type PutElementByIdValidateResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type GetElementConfigurationData = { + body?: never; + path?: never; + query?: never; + url: '/umbraco/management/api/v1/element/configuration'; +}; + +export type GetElementConfigurationErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; +}; + +export type GetElementConfigurationResponses = { + /** + * OK + */ + 200: ElementConfigurationResponseModel; +}; + +export type GetElementConfigurationResponse = GetElementConfigurationResponses[keyof GetElementConfigurationResponses]; + +export type PostElementFolderData = { + body?: CreateFolderRequestModel; + path?: never; + query?: never; + url: '/umbraco/management/api/v1/element/folder'; +}; + +export type PostElementFolderErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type PostElementFolderError = PostElementFolderErrors[keyof PostElementFolderErrors]; + +export type PostElementFolderResponses = { + /** + * Created + */ + 201: unknown; +}; + +export type DeleteElementFolderByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/folder/{id}'; +}; + +export type DeleteElementFolderByIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type DeleteElementFolderByIdError = DeleteElementFolderByIdErrors[keyof DeleteElementFolderByIdErrors]; + +export type DeleteElementFolderByIdResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type GetElementFolderByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/folder/{id}'; +}; + +export type GetElementFolderByIdErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type GetElementFolderByIdError = GetElementFolderByIdErrors[keyof GetElementFolderByIdErrors]; + +export type GetElementFolderByIdResponses = { + /** + * OK + */ + 200: FolderResponseModel; +}; + +export type GetElementFolderByIdResponse = GetElementFolderByIdResponses[keyof GetElementFolderByIdResponses]; + +export type PutElementFolderByIdData = { + body?: UpdateFolderResponseModel; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/element/folder/{id}'; +}; + +export type PutElementFolderByIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; }; -export type DeleteRecycleBinDocumentError = DeleteRecycleBinDocumentErrors[keyof DeleteRecycleBinDocumentErrors]; +export type PutElementFolderByIdError = PutElementFolderByIdErrors[keyof PutElementFolderByIdErrors]; -export type DeleteRecycleBinDocumentResponses = { +export type PutElementFolderByIdResponses = { /** * OK */ 200: unknown; }; -export type DeleteRecycleBinDocumentByIdData = { - body?: never; +export type PutElementFolderByIdMoveData = { + body?: MoveFolderRequestModel; path: { id: string; }; query?: never; - url: '/umbraco/management/api/v1/recycle-bin/document/{id}'; + url: '/umbraco/management/api/v1/element/folder/{id}/move'; }; -export type DeleteRecycleBinDocumentByIdErrors = { +export type PutElementFolderByIdMoveErrors = { /** * Bad Request */ @@ -7081,25 +8388,25 @@ export type DeleteRecycleBinDocumentByIdErrors = { 404: ProblemDetails; }; -export type DeleteRecycleBinDocumentByIdError = DeleteRecycleBinDocumentByIdErrors[keyof DeleteRecycleBinDocumentByIdErrors]; +export type PutElementFolderByIdMoveError = PutElementFolderByIdMoveErrors[keyof PutElementFolderByIdMoveErrors]; -export type DeleteRecycleBinDocumentByIdResponses = { +export type PutElementFolderByIdMoveResponses = { /** * OK */ 200: unknown; }; -export type GetRecycleBinDocumentByIdOriginalParentData = { +export type PutElementFolderByIdMoveToRecycleBinData = { body?: never; path: { id: string; }; query?: never; - url: '/umbraco/management/api/v1/recycle-bin/document/{id}/original-parent'; + url: '/umbraco/management/api/v1/element/folder/{id}/move-to-recycle-bin'; }; -export type GetRecycleBinDocumentByIdOriginalParentErrors = { +export type PutElementFolderByIdMoveToRecycleBinErrors = { /** * Bad Request */ @@ -7118,27 +8425,23 @@ export type GetRecycleBinDocumentByIdOriginalParentErrors = { 404: ProblemDetails; }; -export type GetRecycleBinDocumentByIdOriginalParentError = GetRecycleBinDocumentByIdOriginalParentErrors[keyof GetRecycleBinDocumentByIdOriginalParentErrors]; +export type PutElementFolderByIdMoveToRecycleBinError = PutElementFolderByIdMoveToRecycleBinErrors[keyof PutElementFolderByIdMoveToRecycleBinErrors]; -export type GetRecycleBinDocumentByIdOriginalParentResponses = { +export type PutElementFolderByIdMoveToRecycleBinResponses = { /** * OK */ - 200: ReferenceByIdModel; + 200: unknown; }; -export type GetRecycleBinDocumentByIdOriginalParentResponse = GetRecycleBinDocumentByIdOriginalParentResponses[keyof GetRecycleBinDocumentByIdOriginalParentResponses]; - -export type PutRecycleBinDocumentByIdRestoreData = { - body?: MoveMediaRequestModel; - path: { - id: string; - }; +export type PostElementValidateData = { + body?: CreateElementRequestModel; + path?: never; query?: never; - url: '/umbraco/management/api/v1/recycle-bin/document/{id}/restore'; + url: '/umbraco/management/api/v1/element/validate'; }; -export type PutRecycleBinDocumentByIdRestoreErrors = { +export type PostElementValidateErrors = { /** * Bad Request */ @@ -7157,57 +8460,77 @@ export type PutRecycleBinDocumentByIdRestoreErrors = { 404: ProblemDetails; }; -export type PutRecycleBinDocumentByIdRestoreError = PutRecycleBinDocumentByIdRestoreErrors[keyof PutRecycleBinDocumentByIdRestoreErrors]; +export type PostElementValidateError = PostElementValidateErrors[keyof PostElementValidateErrors]; -export type PutRecycleBinDocumentByIdRestoreResponses = { +export type PostElementValidateResponses = { /** * OK */ 200: unknown; }; -export type GetRecycleBinDocumentChildrenData = { +export type GetItemElementData = { body?: never; path?: never; query?: { - parentId?: string; - skip?: number; - take?: number; + id?: Array; }; - url: '/umbraco/management/api/v1/recycle-bin/document/children'; + url: '/umbraco/management/api/v1/item/element'; }; -export type GetRecycleBinDocumentChildrenErrors = { +export type GetItemElementErrors = { /** * The resource is protected and requires an authentication token */ 401: unknown; - /** - * The authenticated user does not have access to this resource - */ - 403: unknown; }; -export type GetRecycleBinDocumentChildrenResponses = { +export type GetItemElementResponses = { /** * OK */ - 200: PagedDocumentRecycleBinItemResponseModel; + 200: Array; }; -export type GetRecycleBinDocumentChildrenResponse = GetRecycleBinDocumentChildrenResponses[keyof GetRecycleBinDocumentChildrenResponses]; +export type GetItemElementResponse = GetItemElementResponses[keyof GetItemElementResponses]; -export type GetRecycleBinDocumentReferencedByData = { +export type GetItemElementFolderData = { body?: never; path?: never; query?: { - skip?: number; - take?: number; + id?: Array; }; - url: '/umbraco/management/api/v1/recycle-bin/document/referenced-by'; + url: '/umbraco/management/api/v1/item/element/folder'; }; -export type GetRecycleBinDocumentReferencedByErrors = { +export type GetItemElementFolderErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; +}; + +export type GetItemElementFolderResponses = { + /** + * OK + */ + 200: Array; +}; + +export type GetItemElementFolderResponse = GetItemElementFolderResponses[keyof GetItemElementFolderResponses]; + +export type DeleteRecycleBinElementData = { + body?: never; + path?: never; + query?: never; + url: '/umbraco/management/api/v1/recycle-bin/element'; +}; + +export type DeleteRecycleBinElementErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; /** * The resource is protected and requires an authentication token */ @@ -7218,26 +8541,64 @@ export type GetRecycleBinDocumentReferencedByErrors = { 403: unknown; }; -export type GetRecycleBinDocumentReferencedByResponses = { +export type DeleteRecycleBinElementError = DeleteRecycleBinElementErrors[keyof DeleteRecycleBinElementErrors]; + +export type DeleteRecycleBinElementResponses = { /** * OK */ - 200: PagedIReferenceResponseModel; + 200: unknown; }; -export type GetRecycleBinDocumentReferencedByResponse = GetRecycleBinDocumentReferencedByResponses[keyof GetRecycleBinDocumentReferencedByResponses]; +export type DeleteRecycleBinElementByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/recycle-bin/element/{id}'; +}; -export type GetRecycleBinDocumentRootData = { +export type DeleteRecycleBinElementByIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type DeleteRecycleBinElementByIdError = DeleteRecycleBinElementByIdErrors[keyof DeleteRecycleBinElementByIdErrors]; + +export type DeleteRecycleBinElementByIdResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type GetRecycleBinElementChildrenData = { body?: never; path?: never; query?: { + parentId?: string; skip?: number; take?: number; }; - url: '/umbraco/management/api/v1/recycle-bin/document/root'; + url: '/umbraco/management/api/v1/recycle-bin/element/children'; }; -export type GetRecycleBinDocumentRootErrors = { +export type GetRecycleBinElementChildrenErrors = { /** * The resource is protected and requires an authentication token */ @@ -7248,28 +8609,29 @@ export type GetRecycleBinDocumentRootErrors = { 403: unknown; }; -export type GetRecycleBinDocumentRootResponses = { +export type GetRecycleBinElementChildrenResponses = { /** * OK */ - 200: PagedDocumentRecycleBinItemResponseModel; + 200: PagedElementRecycleBinItemResponseModel; }; -export type GetRecycleBinDocumentRootResponse = GetRecycleBinDocumentRootResponses[keyof GetRecycleBinDocumentRootResponses]; +export type GetRecycleBinElementChildrenResponse = GetRecycleBinElementChildrenResponses[keyof GetRecycleBinElementChildrenResponses]; -export type GetRecycleBinDocumentSiblingsData = { +export type DeleteRecycleBinElementFolderByIdData = { body?: never; - path?: never; - query?: { - target?: string; - before?: number; - after?: number; - dataTypeId?: string; + path: { + id: string; }; - url: '/umbraco/management/api/v1/recycle-bin/document/siblings'; + query?: never; + url: '/umbraco/management/api/v1/recycle-bin/element/folder/{id}'; }; -export type GetRecycleBinDocumentSiblingsErrors = { +export type DeleteRecycleBinElementFolderByIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; /** * The resource is protected and requires an authentication token */ @@ -7278,27 +8640,32 @@ export type GetRecycleBinDocumentSiblingsErrors = { * The authenticated user does not have access to this resource */ 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; }; -export type GetRecycleBinDocumentSiblingsResponses = { +export type DeleteRecycleBinElementFolderByIdError = DeleteRecycleBinElementFolderByIdErrors[keyof DeleteRecycleBinElementFolderByIdErrors]; + +export type DeleteRecycleBinElementFolderByIdResponses = { /** * OK */ - 200: SubsetDocumentRecycleBinItemResponseModel; + 200: unknown; }; -export type GetRecycleBinDocumentSiblingsResponse = GetRecycleBinDocumentSiblingsResponses[keyof GetRecycleBinDocumentSiblingsResponses]; - -export type GetTreeDocumentAncestorsData = { +export type GetRecycleBinElementRootData = { body?: never; path?: never; query?: { - descendantId?: string; + skip?: number; + take?: number; }; - url: '/umbraco/management/api/v1/tree/document/ancestors'; + url: '/umbraco/management/api/v1/recycle-bin/element/root'; }; -export type GetTreeDocumentAncestorsErrors = { +export type GetRecycleBinElementRootErrors = { /** * The resource is protected and requires an authentication token */ @@ -7309,28 +8676,28 @@ export type GetTreeDocumentAncestorsErrors = { 403: unknown; }; -export type GetTreeDocumentAncestorsResponses = { +export type GetRecycleBinElementRootResponses = { /** * OK */ - 200: Array; + 200: PagedElementRecycleBinItemResponseModel; }; -export type GetTreeDocumentAncestorsResponse = GetTreeDocumentAncestorsResponses[keyof GetTreeDocumentAncestorsResponses]; +export type GetRecycleBinElementRootResponse = GetRecycleBinElementRootResponses[keyof GetRecycleBinElementRootResponses]; -export type GetTreeDocumentChildrenData = { +export type GetRecycleBinElementSiblingsData = { body?: never; path?: never; query?: { - parentId?: string; - skip?: number; - take?: number; + target?: string; + before?: number; + after?: number; dataTypeId?: string; }; - url: '/umbraco/management/api/v1/tree/document/children'; + url: '/umbraco/management/api/v1/recycle-bin/element/siblings'; }; -export type GetTreeDocumentChildrenErrors = { +export type GetRecycleBinElementSiblingsErrors = { /** * The resource is protected and requires an authentication token */ @@ -7341,27 +8708,25 @@ export type GetTreeDocumentChildrenErrors = { 403: unknown; }; -export type GetTreeDocumentChildrenResponses = { +export type GetRecycleBinElementSiblingsResponses = { /** * OK */ - 200: PagedDocumentTreeItemResponseModel; + 200: SubsetElementRecycleBinItemResponseModel; }; -export type GetTreeDocumentChildrenResponse = GetTreeDocumentChildrenResponses[keyof GetTreeDocumentChildrenResponses]; +export type GetRecycleBinElementSiblingsResponse = GetRecycleBinElementSiblingsResponses[keyof GetRecycleBinElementSiblingsResponses]; -export type GetTreeDocumentRootData = { +export type GetTreeElementAncestorsData = { body?: never; path?: never; query?: { - skip?: number; - take?: number; - dataTypeId?: string; + descendantId?: string; }; - url: '/umbraco/management/api/v1/tree/document/root'; + url: '/umbraco/management/api/v1/tree/element/ancestors'; }; -export type GetTreeDocumentRootErrors = { +export type GetTreeElementAncestorsErrors = { /** * The resource is protected and requires an authentication token */ @@ -7372,28 +8737,28 @@ export type GetTreeDocumentRootErrors = { 403: unknown; }; -export type GetTreeDocumentRootResponses = { +export type GetTreeElementAncestorsResponses = { /** * OK */ - 200: PagedDocumentTreeItemResponseModel; + 200: Array; }; -export type GetTreeDocumentRootResponse = GetTreeDocumentRootResponses[keyof GetTreeDocumentRootResponses]; +export type GetTreeElementAncestorsResponse = GetTreeElementAncestorsResponses[keyof GetTreeElementAncestorsResponses]; -export type GetTreeDocumentSiblingsData = { +export type GetTreeElementChildrenData = { body?: never; path?: never; query?: { - target?: string; - before?: number; - after?: number; - dataTypeId?: string; + parentId?: string; + skip?: number; + take?: number; + foldersOnly?: boolean; }; - url: '/umbraco/management/api/v1/tree/document/siblings'; + url: '/umbraco/management/api/v1/tree/element/children'; }; -export type GetTreeDocumentSiblingsErrors = { +export type GetTreeElementChildrenErrors = { /** * The resource is protected and requires an authentication token */ @@ -7404,23 +8769,27 @@ export type GetTreeDocumentSiblingsErrors = { 403: unknown; }; -export type GetTreeDocumentSiblingsResponses = { +export type GetTreeElementChildrenResponses = { /** * OK */ - 200: SubsetDocumentTreeItemResponseModel; + 200: PagedElementTreeItemResponseModel; }; -export type GetTreeDocumentSiblingsResponse = GetTreeDocumentSiblingsResponses[keyof GetTreeDocumentSiblingsResponses]; +export type GetTreeElementChildrenResponse = GetTreeElementChildrenResponses[keyof GetTreeElementChildrenResponses]; -export type PostDynamicRootQueryData = { - body?: DynamicRootRequestModel; +export type GetTreeElementRootData = { + body?: never; path?: never; - query?: never; - url: '/umbraco/management/api/v1/dynamic-root/query'; + query?: { + skip?: number; + take?: number; + foldersOnly?: boolean; + }; + url: '/umbraco/management/api/v1/tree/element/root'; }; -export type PostDynamicRootQueryErrors = { +export type GetTreeElementRootErrors = { /** * The resource is protected and requires an authentication token */ @@ -7431,23 +8800,28 @@ export type PostDynamicRootQueryErrors = { 403: unknown; }; -export type PostDynamicRootQueryResponses = { +export type GetTreeElementRootResponses = { /** * OK */ - 200: DynamicRootResponseModel; + 200: PagedElementTreeItemResponseModel; }; -export type PostDynamicRootQueryResponse = PostDynamicRootQueryResponses[keyof PostDynamicRootQueryResponses]; +export type GetTreeElementRootResponse = GetTreeElementRootResponses[keyof GetTreeElementRootResponses]; -export type GetDynamicRootStepsData = { +export type GetTreeElementSiblingsData = { body?: never; path?: never; - query?: never; - url: '/umbraco/management/api/v1/dynamic-root/steps'; + query?: { + target?: string; + before?: number; + after?: number; + foldersOnly?: boolean; + }; + url: '/umbraco/management/api/v1/tree/element/siblings'; }; -export type GetDynamicRootStepsErrors = { +export type GetTreeElementSiblingsErrors = { /** * The resource is protected and requires an authentication token */ @@ -7458,14 +8832,14 @@ export type GetDynamicRootStepsErrors = { 403: unknown; }; -export type GetDynamicRootStepsResponses = { +export type GetTreeElementSiblingsResponses = { /** * OK */ - 200: Array; + 200: SubsetElementTreeItemResponseModel; }; -export type GetDynamicRootStepsResponse = GetDynamicRootStepsResponses[keyof GetDynamicRootStepsResponses]; +export type GetTreeElementSiblingsResponse = GetTreeElementSiblingsResponses[keyof GetTreeElementSiblingsResponses]; export type GetHealthCheckGroupData = { body?: never; @@ -9680,8 +11054,14 @@ export type GetMediaByIdReferencedByErrors = { * The authenticated user does not have access to this resource */ 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; }; +export type GetMediaByIdReferencedByError = GetMediaByIdReferencedByErrors[keyof GetMediaByIdReferencedByErrors]; + export type GetMediaByIdReferencedByResponses = { /** * OK @@ -9712,8 +11092,14 @@ export type GetMediaByIdReferencedDescendantsErrors = { * The authenticated user does not have access to this resource */ 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; }; +export type GetMediaByIdReferencedDescendantsError = GetMediaByIdReferencedDescendantsErrors[keyof GetMediaByIdReferencedDescendantsErrors]; + export type GetMediaByIdReferencedDescendantsResponses = { /** * OK @@ -11526,8 +12912,14 @@ export type GetMemberByIdReferencedByErrors = { * The authenticated user does not have access to this resource */ 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; }; +export type GetMemberByIdReferencedByError = GetMemberByIdReferencedByErrors[keyof GetMemberByIdReferencedByErrors]; + export type GetMemberByIdReferencedByResponses = { /** * OK @@ -11558,8 +12950,14 @@ export type GetMemberByIdReferencedDescendantsErrors = { * The authenticated user does not have access to this resource */ 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; }; +export type GetMemberByIdReferencedDescendantsError = GetMemberByIdReferencedDescendantsErrors[keyof GetMemberByIdReferencedDescendantsErrors]; + export type GetMemberByIdReferencedDescendantsResponses = { /** * OK @@ -16586,6 +17984,37 @@ export type GetUserCurrentPermissionsDocumentResponses = { export type GetUserCurrentPermissionsDocumentResponse = GetUserCurrentPermissionsDocumentResponses[keyof GetUserCurrentPermissionsDocumentResponses]; +export type GetUserCurrentPermissionsElementData = { + body?: never; + path?: never; + query?: { + id?: Array; + }; + url: '/umbraco/management/api/v1/user/current/permissions/element'; +}; + +export type GetUserCurrentPermissionsElementErrors = { + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * Not Found + */ + 404: ProblemDetails; +}; + +export type GetUserCurrentPermissionsElementError = GetUserCurrentPermissionsElementErrors[keyof GetUserCurrentPermissionsElementErrors]; + +export type GetUserCurrentPermissionsElementResponses = { + /** + * OK + */ + 200: Array; +}; + +export type GetUserCurrentPermissionsElementResponse = GetUserCurrentPermissionsElementResponses[keyof GetUserCurrentPermissionsElementResponses]; + export type GetUserCurrentPermissionsMediaData = { body?: never; path?: never; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/extensions/entity-bulk-action.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/extensions/entity-bulk-action.extension.ts index 4d5b2adcb532..01a3a81f9a8c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/extensions/entity-bulk-action.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/extensions/entity-bulk-action.extension.ts @@ -32,7 +32,7 @@ export interface MetaEntityBulkActionDefaultKind extends MetaEntityBulkAction { * "icon-grid" * ] */ - icon: string; + icon?: string; /** * The friendly name of the action to perform diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/constants.ts index 55b3a7405c7c..765533939706 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/constants.ts @@ -1,2 +1,3 @@ export * from './restore-from-recycle-bin/constants.js'; export * from './trash/constants.js'; +export * from './trash-folder/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/index.ts index bd395ad28f18..af73aeb713d6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/index.ts @@ -1,4 +1,5 @@ export { UmbTrashEntityAction, UmbEntityTrashedEvent } from './trash/index.js'; +export { UmbTrashFolderEntityAction } from './trash-folder/index.js'; export { UmbRestoreFromRecycleBinEntityAction, UmbEntityRestoredFromRecycleBinEvent, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/constants.ts new file mode 100644 index 000000000000..54c9e2f61f7f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/constants.ts @@ -0,0 +1 @@ +export { UMB_ENTITY_ACTION_TRASH_FOLDER_KIND_MANIFEST } from './trash-folder.action.kind.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/index.ts new file mode 100644 index 000000000000..6494d58fecbc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/index.ts @@ -0,0 +1,2 @@ +export { UmbTrashFolderEntityAction } from './trash-folder.action.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/manifests.ts new file mode 100644 index 000000000000..f6f29bb6977b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/manifests.ts @@ -0,0 +1,4 @@ +import { manifest as trashKindManifest } from './trash-folder.action.kind.js'; +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [trashKindManifest]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/trash-folder.action.kind.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/trash-folder.action.kind.ts new file mode 100644 index 000000000000..3f7003675f53 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/trash-folder.action.kind.ts @@ -0,0 +1,23 @@ +import { UMB_ENTITY_ACTION_TRASH_KIND_MANIFEST } from '../trash/constants.js'; +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const UMB_ENTITY_ACTION_TRASH_FOLDER_KIND_MANIFEST: UmbExtensionManifestKind = { + type: 'kind', + alias: 'Umb.Kind.EntityAction.TrashFolder', + matchKind: 'trashFolder', + matchType: 'entityAction', + manifest: { + ...UMB_ENTITY_ACTION_TRASH_KIND_MANIFEST.manifest, + type: 'entityAction', + kind: 'trashFolder', + api: () => import('./trash-folder.action.js'), + weight: 1150, + meta: { + icon: 'icon-trash', + label: '#actions_trash', + additionalOptions: true, + }, + }, +}; + +export const manifest = UMB_ENTITY_ACTION_TRASH_FOLDER_KIND_MANIFEST; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/trash-folder.action.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/trash-folder.action.ts new file mode 100644 index 000000000000..50c91f024cc2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/trash-folder.action.ts @@ -0,0 +1,92 @@ +import type { UmbRecycleBinRepository } from '../../recycle-bin-repository.interface.js'; +import { UmbEntityTrashedEvent } from '../trash/trash.event.js'; +import type { UmbFolderModel } from '../../../tree/types.js'; +import type { MetaEntityActionTrashFolderKind } from './types.js'; +import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; +import { UmbEntityActionBase, UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; +import type { UmbDetailRepositoryBase } from '@umbraco-cms/backoffice/repository'; + +/** + * Entity action for trashing an item. + * @class UmbTrashFolderEntityAction + * @augments {UmbEntityActionBase} + */ +export class UmbTrashFolderEntityAction< + MetaKindType extends MetaEntityActionTrashFolderKind = MetaEntityActionTrashFolderKind, +> extends UmbEntityActionBase { + #localize = new UmbLocalizationController(this); + + /** + * Executes the action. + * @memberof UmbTrashFolderEntityAction + */ + override async execute() { + if (!this.args.unique) throw new Error('Cannot trash a folder without a unique identifier.'); + + const folder = await this.#requestFolder(); + + await this._confirmTrash(folder); + + const recycleBinRepository = await createExtensionApiByAlias( + this, + this.args.meta.recycleBinRepositoryAlias, + ); + + const { error } = await recycleBinRepository.requestTrash({ unique: this.args.unique }); + if (error) { + throw error; + } + + this.#notify(); + } + + protected async _confirmTrash(folder: UmbFolderModel) { + const headline = '#actions_trash'; + const message = '#defaultdialogs_confirmTrash'; + + // TODO: handle items with variants + await umbConfirmModal(this, { + headline, + content: this.#localize.string(message, folder.name), + color: 'danger', + confirmLabel: '#actions_trash', + }); + } + + async #requestFolder() { + if (!this.args.unique) throw new Error('Cannot trash a folder without a unique identifier.'); + + const folderRepository = await createExtensionApiByAlias>( + this, + this.args.meta.folderRepositoryAlias, + ); + + const { data: folder } = await folderRepository.requestByUnique(this.args.unique); + if (!folder) throw new Error('Folder not found.'); + return folder; + } + + async #notify() { + const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); + if (!actionEventContext) throw new Error('Action event context is missing.'); + + const event = new UmbRequestReloadStructureForEntityEvent({ + unique: this.args.unique, + entityType: this.args.entityType, + }); + + actionEventContext.dispatchEvent(event); + + const trashedEvent = new UmbEntityTrashedEvent({ + unique: this.args.unique, + entityType: this.args.entityType, + }); + + actionEventContext.dispatchEvent(trashedEvent); + } +} + +export { UmbTrashFolderEntityAction as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/types.ts new file mode 100644 index 000000000000..ae6a9bd161a2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/trash-folder/types.ts @@ -0,0 +1,17 @@ +import type { ManifestEntityAction, MetaEntityActionDefaultKind } from '@umbraco-cms/backoffice/entity-action'; + +export interface ManifestEntityActionTrashFolderKind extends ManifestEntityAction { + type: 'entityAction'; + kind: 'trashFolder'; +} + +export interface MetaEntityActionTrashFolderKind extends MetaEntityActionDefaultKind { + folderRepositoryAlias: string; + recycleBinRepositoryAlias: string; +} + +declare global { + interface UmbExtensionManifestMap { + umbTrashFolderEntityActionKind: ManifestEntityActionTrashFolderKind; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/types.ts index 9919f7ca786c..fcde9a7c7b45 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/types.ts @@ -1,3 +1,4 @@ export type * from './trash/types.js'; +export type * from './trash-folder/types.js'; export type * from './restore-from-recycle-bin/types.js'; export type * from './empty-recycle-bin/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/index.ts index 04706792bd1d..a817f099c416 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/index.ts @@ -1,3 +1,4 @@ +export * from './collection-action/index.js'; export * from './constants.js'; export * from './entity-action/index.js'; export * from './entity-bulk-action/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/manifests.ts index 693c5737bd86..1191070f97af 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/manifests.ts @@ -3,6 +3,7 @@ import { manifests as conditionManifests } from './conditions/manifests.js'; import { manifests as emptyRecycleBinEntityActionManifests } from './entity-action/empty-recycle-bin/manifests.js'; import { manifests as restoreFromRecycleBinEntityActionManifests } from './entity-action/restore-from-recycle-bin/manifests.js'; import { manifests as trashEntityActionManifests } from './entity-action/trash/manifests.js'; +import { manifests as trashFolderEntityActionManifests } from './entity-action/trash-folder/manifests.js'; import { manifests as trashEntityBulkActionManifests } from './entity-bulk-action/bulk-trash/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; @@ -14,6 +15,7 @@ export const manifests: Array = ...emptyRecycleBinEntityActionManifests, ...restoreFromRecycleBinEntityActionManifests, ...trashEntityActionManifests, + ...trashFolderEntityActionManifests, ...trashEntityBulkActionManifests, ...treeManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/types.ts index 52db4d7f185a..cb9b4c8eefc0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/types.ts @@ -1,6 +1,7 @@ export type * from './conditions/types.js'; export type * from './entity-action/types.js'; export type * from './entity-bulk-action/types.js'; +export type * from './tree/tree-item/types.js'; export type { UmbRecycleBinDataSource } from './recycle-bin-data-source.interface.js'; export type { UmbRecycleBinRepository } from './recycle-bin-repository.interface.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/folder/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/folder/types.ts index 3594aaba335b..e82a6edfd985 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/folder/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/folder/types.ts @@ -2,6 +2,7 @@ import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; import type { MetaEntityActionDefaultKind } from '@umbraco-cms/backoffice/entity-action'; export type * from './entity-action/types.js'; +export type * from './entity-create-option-action/types.js'; export interface UmbFolderModel extends UmbEntityModel { name: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/input-document-granular-user-permission/input-document-granular-user-permission.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/input-document-granular-user-permission/input-document-granular-user-permission.element.ts index a71c2f1db880..376c005c7e40 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/input-document-granular-user-permission/input-document-granular-user-permission.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/input-document-granular-user-permission/input-document-granular-user-permission.element.ts @@ -205,7 +205,7 @@ export class UmbInputDocumentGranularUserPermissionElement extends UUIFormContro #renderIcon(item: UmbDocumentItemModel) { if (!item.documentType.icon) return; - return html``; + return html``; } #renderIsTrashed(item: UmbDocumentItemModel) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/action/manifests.ts new file mode 100644 index 000000000000..4a054a9b47a5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/action/manifests.ts @@ -0,0 +1,19 @@ +import { UMB_ELEMENT_COLLECTION_ALIAS } from '../constants.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'collectionAction', + kind: 'create', + name: 'Element Collection Create Action', + alias: 'Umb.CollectionAction.Element.Create', + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_ELEMENT_COLLECTION_ALIAS, + }, + { alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/constants.ts new file mode 100644 index 000000000000..92d1ddb200a1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/constants.ts @@ -0,0 +1,2 @@ +export const UMB_ELEMENT_COLLECTION_ALIAS = 'Umb.Collection.Element'; +export * from './repository/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/index.ts new file mode 100644 index 000000000000..6c11f6abbb12 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/index.ts @@ -0,0 +1,2 @@ +export * from './constants.js'; +export * from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/manifests.ts new file mode 100644 index 000000000000..2c9f7c993d7c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/manifests.ts @@ -0,0 +1,20 @@ +import { manifests as actionManifests } from './action/manifests.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as viewManifests } from './views/manifests.js'; +import { UMB_ELEMENT_COLLECTION_ALIAS } from './constants.js'; +import { UMB_ELEMENT_COLLECTION_REPOSITORY_ALIAS } from './repository/index.js'; + +export const manifests: Array = [ + { + type: 'collection', + kind: 'default', + alias: UMB_ELEMENT_COLLECTION_ALIAS, + name: 'Element Collection', + meta: { + repositoryAlias: UMB_ELEMENT_COLLECTION_REPOSITORY_ALIAS, + }, + }, + ...actionManifests, + ...repositoryManifests, + ...viewManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/constants.ts new file mode 100644 index 000000000000..6833d7504229 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_ELEMENT_COLLECTION_REPOSITORY_ALIAS = 'Umb.Repository.Element.Collection'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/element-collection.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/element-collection.repository.ts new file mode 100644 index 000000000000..3e775f5eb20e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/element-collection.repository.ts @@ -0,0 +1,31 @@ +import { UmbElementTreeRepository } from '../../tree/element-tree.repository.js'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity'; +import type { UmbCollectionFilterModel, UmbCollectionRepository } from '@umbraco-cms/backoffice/collection'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export class UmbElementCollectionRepository extends UmbRepositoryBase implements UmbCollectionRepository { + #treeRepository = new UmbElementTreeRepository(this); + + async requestCollection(filter: UmbCollectionFilterModel) { + // TODO: get parent from args + const entityContext = await this.getContext(UMB_ENTITY_CONTEXT); + if (!entityContext) throw new Error('Entity context not found'); + + const entityType = entityContext.getEntityType(); + const unique = entityContext.getUnique(); + + if (!entityType) throw new Error('Entity type not found'); + if (unique === undefined) throw new Error('Unique not found'); + + const parent: UmbEntityModel = { entityType, unique }; + + if (parent.unique === null) { + return this.#treeRepository.requestTreeRootItems({ skip: filter.skip, take: filter.take }); + } else { + return this.#treeRepository.requestTreeItemsOf({ parent, skip: filter.skip, take: filter.take }); + } + } +} + +export { UmbElementCollectionRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/index.ts new file mode 100644 index 000000000000..4f07201dcf0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/index.ts @@ -0,0 +1 @@ +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/manifests.ts new file mode 100644 index 000000000000..216340d90387 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/repository/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_ELEMENT_COLLECTION_REPOSITORY_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_ELEMENT_COLLECTION_REPOSITORY_ALIAS, + name: 'Element Collection Repository', + api: () => import('./element-collection.repository.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/types.ts new file mode 100644 index 000000000000..a45264779f6f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/types.ts @@ -0,0 +1,6 @@ +import type { UmbCollectionFilterModel } from '@umbraco-cms/backoffice/collection'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbDocumentTypeTreeItemChildrenCollectionFilterModel extends UmbCollectionFilterModel { + parent: UmbEntityModel; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/views/element-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/views/element-table-collection-view.element.ts new file mode 100644 index 000000000000..6dd216e5fc39 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/views/element-table-collection-view.element.ts @@ -0,0 +1,166 @@ +import type { UmbElementTreeItemModel } from '../../tree/types.js'; +import { UMB_EDIT_ELEMENT_FOLDER_WORKSPACE_PATH_PATTERN } from '../../folder/workspace/constants.js'; +import { UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN } from '../../paths.js'; +import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UMB_COLLECTION_CONTEXT } from '@umbraco-cms/backoffice/collection'; +import type { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection'; +import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; +import type { + UmbTableColumn, + UmbTableConfig, + UmbTableDeselectedEvent, + UmbTableElement, + UmbTableItem, + UmbTableSelectedEvent, +} from '@umbraco-cms/backoffice/components'; + +@customElement('umb-element-tree-item-table-collection-view') +export class UmbElementTreeItemTableCollectionViewElement extends UmbLitElement { + @state() + private _items?: Array; + + @state() + private _selection: Array = []; + + @state() + private _tableItems: Array = []; + + #collectionContext?: UmbDefaultCollectionContext; + + #routeBuilder?: UmbModalRouteBuilder; + + #tableConfig: UmbTableConfig = { allowSelection: true }; + + #tableColumns: Array = [ + { + name: this.localize.term('general_name'), + alias: 'name', + }, + { name: '', alias: 'entityActions', align: 'right' }, + ]; + + constructor() { + super(); + + this.consumeContext(UMB_COLLECTION_CONTEXT, (collectionContext) => { + this.#collectionContext = collectionContext; + collectionContext?.setupView(this); + this.#observeCollectionContext(); + }); + } + + #observeCollectionContext() { + if (!this.#collectionContext) return; + + this.observe( + this.#collectionContext.items, + (items) => { + this._items = items; + this.#createTableItems(); + }, + '_observeItems', + ); + + this.observe( + this.#collectionContext.selection.selection, + (selection) => { + if (selection) { + this._selection = selection as string[]; + } + }, + '_observeSelection', + ); + + this.observe( + this.#collectionContext.workspacePathBuilder, + (routeBuilder) => { + this.#routeBuilder = routeBuilder; + this.#createTableItems(); + }, + '_observeWorkspacePathBuilder', + ); + } + + #createTableItems() { + if (!this._items) return; + const routeBuilder = this.#routeBuilder; + if (!routeBuilder) return; + + this._tableItems = this._items.map((item) => { + const modalEditPath = + routeBuilder({ entityType: item.entityType }) + + UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN.generateLocal({ unique: item.unique }); + + const inlineEditPath = UMB_EDIT_ELEMENT_FOLDER_WORKSPACE_PATH_PATTERN.generateAbsolute({ + unique: item.unique, + }); + + return { + id: item.unique, + icon: item.isFolder && !item.icon ? 'icon-folder' : item.icon, + data: [ + { + columnAlias: 'name', + value: html` + + `, + }, + { + columnAlias: 'entityActions', + value: html``, + }, + ], + }; + }); + } + + #onSelect(event: UmbTableSelectedEvent) { + event.stopPropagation(); + const table = event.target as UmbTableElement; + const selection = table.selection; + this.#collectionContext?.selection.setSelection(selection); + } + + #onDeselect(event: UmbTableDeselectedEvent) { + event.stopPropagation(); + const table = event.target as UmbTableElement; + const selection = table.selection; + this.#collectionContext?.selection.setSelection(selection); + } + + override render() { + return html` + + + `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: flex; + flex-direction: column; + } + `, + ]; +} + +export { UmbElementTreeItemTableCollectionViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-tree-item-table-collection-view': UmbElementTreeItemTableCollectionViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/collection/views/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/views/manifests.ts new file mode 100644 index 000000000000..0877d22e1708 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/collection/views/manifests.ts @@ -0,0 +1,23 @@ +import { UMB_ELEMENT_COLLECTION_ALIAS } from '../constants.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; + +export const manifests: Array = [ + { + type: 'collectionView', + alias: 'Umb.CollectionView.Element.Table', + name: 'Element Table Collection View', + element: () => import('./element-table-collection-view.element.js'), + weight: 300, + meta: { + label: 'Table', + icon: 'icon-table', + pathName: 'table', + }, + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_ELEMENT_COLLECTION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/configuration/configuration.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/configuration/configuration.repository.ts new file mode 100644 index 000000000000..93042e2b03a5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/configuration/configuration.repository.ts @@ -0,0 +1,21 @@ +import { UmbElementConfigurationServerDataSource } from './configuration.server.data-source.js'; +import type { UmbElementConfigurationModel } from './types.js'; +import { UmbRepositoryBase, type UmbRepositoryResponse } from '@umbraco-cms/backoffice/repository'; + +/** + * @description - Repository for Element configuration. + * @exports + * @class UmbElementConfigurationRepository + * @augments UmbRepositoryBase + */ +export class UmbElementConfigurationRepository extends UmbRepositoryBase { + #serverDataSource = new UmbElementConfigurationServerDataSource(this); + /** + * Requests the Element configuration + * @returns {Promise>} - The element configuration. + * @memberof UmbElementConfigurationRepository + */ + requestConfiguration(): Promise> { + return this.#serverDataSource.getConfiguration(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/configuration/configuration.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/configuration/configuration.server.data-source.ts new file mode 100644 index 000000000000..539184a858d9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/configuration/configuration.server.data-source.ts @@ -0,0 +1,28 @@ +import type { UmbElementConfigurationModel } from './types.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; + +export class UmbElementConfigurationServerDataSource extends UmbControllerBase { + /** + * Gets the element configuration from the server. + * @returns {Promise>} - The element configuration. + * @memberof UmbElementConfigurationServerDataSource + */ + async getConfiguration(): Promise> { + const { data, error } = await tryExecute(this, ElementService.getElementConfiguration()); + + if (data) { + const mappedData: UmbElementConfigurationModel = { + disableDeleteWhenReferenced: data.disableDeleteWhenReferenced, + disableUnpublishWhenReferenced: data.disableUnpublishWhenReferenced, + allowEditInvariantFromNonDefault: data.allowEditInvariantFromNonDefault, + }; + + return { data: mappedData }; + } + + return { error }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/configuration/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/configuration/types.ts new file mode 100644 index 000000000000..64e2d6db7777 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/configuration/types.ts @@ -0,0 +1,5 @@ +export interface UmbElementConfigurationModel { + disableDeleteWhenReferenced: boolean; + disableUnpublishWhenReferenced: boolean; + allowEditInvariantFromNonDefault: boolean; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/element-create-option-action.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/element-create-option-action.ts new file mode 100644 index 000000000000..feaef5eb20e3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/element-create-option-action.ts @@ -0,0 +1,40 @@ +import type { UmbElementEntityTypeUnion } from '../../entity.js'; +import { UMB_CREATE_ELEMENT_WORKSPACE_PATH_PATTERN } from '../../paths.js'; +import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { UmbEntityCreateOptionActionBase } from '@umbraco-cms/backoffice/entity-create-option-action'; +import { UMB_DOCUMENT_TYPE_PICKER_MODAL } from '@umbraco-cms/backoffice/document-type'; +import type { MetaEntityCreateOptionAction } from '@umbraco-cms/backoffice/entity-create-option-action'; +import type { UmbDocumentTypeTreeItemModel } from '@umbraco-cms/backoffice/document-type'; + +export class UmbDefaultElementCreateOptionAction extends UmbEntityCreateOptionActionBase { + override async execute(): Promise { + const parentEntityType = this.args.entityType as UmbElementEntityTypeUnion; + if (!parentEntityType) throw new Error('Entity type is required to create an element'); + + const parentUnique = this.args.unique ?? null; + + const value = await umbOpenModal(this, UMB_DOCUMENT_TYPE_PICKER_MODAL, { + data: { + hideTreeRoot: true, + pickableFilter: (item: UmbDocumentTypeTreeItemModel) => item.isElement, + }, + }); + + const selection = value.selection.filter((x) => x !== null); + + const documentTypeUnique = selection[0]; + if (!documentTypeUnique) throw new Error('A document type must be selected to create an element'); + + history.pushState( + null, + '', + UMB_CREATE_ELEMENT_WORKSPACE_PATH_PATTERN.generateAbsolute({ + parentEntityType, + parentUnique, + documentTypeUnique, + }), + ); + } +} + +export { UmbDefaultElementCreateOptionAction as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/manifests.ts new file mode 100644 index 000000000000..1073eef1dc87 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/create/manifests.ts @@ -0,0 +1,68 @@ +import { UMB_ELEMENT_FOLDER_ENTITY_TYPE, UMB_ELEMENT_ROOT_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS } from '../../folder/constants.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_CREATE, +} from '../../user-permissions/constants.js'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; +import type { ManifestEntityAction } from '@umbraco-cms/backoffice/entity-action'; +import type { ManifestEntityCreateOptionAction } from '@umbraco-cms/backoffice/entity-create-option-action'; +import type { ManifestEntityCreateOptionActionFolderKind } from '@umbraco-cms/backoffice/tree'; + +const createEntityAction: ManifestEntityAction = { + type: 'entityAction', + kind: 'create', + alias: 'Umb.EntityAction.Element.Create', + name: 'Create Element Entity Action', + weight: 1200, + forEntityTypes: [UMB_ELEMENT_ROOT_ENTITY_TYPE, UMB_ELEMENT_FOLDER_ENTITY_TYPE], + meta: { + icon: 'icon-add', + label: '#actions_createFor', + additionalOptions: true, + headline: '#create_createUnder #treeHeaders_elements', + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_CREATE], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], +}; + +const elementCreateOptionAction: ManifestEntityCreateOptionAction = { + type: 'entityCreateOptionAction', + alias: 'Umb.EntityCreateOptionAction.Element.Default', + name: 'Default Element Entity Create Option Action', + weight: 100, + api: () => import('./element-create-option-action.js'), + forEntityTypes: [UMB_ELEMENT_ROOT_ENTITY_TYPE, UMB_ELEMENT_FOLDER_ENTITY_TYPE], + meta: { + icon: 'icon-document', + label: '#create_element', + description: '#create_elementDescription', + }, +}; + +const folderCreateOptionAction: ManifestEntityCreateOptionActionFolderKind = { + type: 'entityCreateOptionAction', + kind: 'folder', + alias: 'Umb.EntityCreateOptionAction.Element.Folder', + name: 'Element Folder Entity Create Option Action', + forEntityTypes: [UMB_ELEMENT_ROOT_ENTITY_TYPE, UMB_ELEMENT_FOLDER_ENTITY_TYPE], + meta: { + icon: 'icon-folder', + label: '#create_folder', + additionalOptions: true, + folderRepositoryAlias: UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS, + }, +}; + +export const manifests: Array = [ + createEntityAction, + elementCreateOptionAction, + folderCreateOptionAction, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/constants.ts new file mode 100644 index 000000000000..41a409dec1f0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/constants.ts @@ -0,0 +1 @@ +export * from './repository/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/index.ts new file mode 100644 index 000000000000..59ec502c300a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/index.ts @@ -0,0 +1 @@ +export { UmbDuplicateElementRepository } from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/manifests.ts new file mode 100644 index 000000000000..2b2c6c4434ec --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/manifests.ts @@ -0,0 +1,33 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_TREE_ALIAS, UMB_ELEMENT_TREE_REPOSITORY_ALIAS } from '../../tree/index.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_DUPLICATE, +} from '../../user-permissions/constants.js'; +import { UMB_DUPLICATE_ELEMENT_REPOSITORY_ALIAS } from './repository/index.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'entityAction', + kind: 'duplicateTo', + alias: 'Umb.EntityAction.Element.DuplicateTo', + name: 'Duplicate Element To Entity Action', + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + duplicateRepositoryAlias: UMB_DUPLICATE_ELEMENT_REPOSITORY_ALIAS, + treeAlias: UMB_ELEMENT_TREE_ALIAS, + treeRepositoryAlias: UMB_ELEMENT_TREE_REPOSITORY_ALIAS, + foldersOnly: true, + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_DUPLICATE], + }, + { alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS }, + ], + }, + ...repositoryManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/constants.ts new file mode 100644 index 000000000000..14b6bf29c165 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_DUPLICATE_ELEMENT_REPOSITORY_ALIAS = 'Umb.Repository.Element.Duplicate'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/element-duplicate.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/element-duplicate.repository.ts new file mode 100644 index 000000000000..2bb3f7ae5018 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/element-duplicate.repository.ts @@ -0,0 +1,25 @@ +import { UmbDuplicateElementServerDataSource } from './element-duplicate.server.data-source.js'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import type { UmbDuplicateToRepository, UmbDuplicateToRequestArgs } from '@umbraco-cms/backoffice/tree'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; + +export class UmbDuplicateElementRepository extends UmbRepositoryBase implements UmbDuplicateToRepository { + #duplicateSource = new UmbDuplicateElementServerDataSource(this); + + async requestDuplicateTo(args: UmbDuplicateToRequestArgs) { + const { error } = await this.#duplicateSource.duplicateTo(args); + + if (!error) { + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + if (!notificationContext) { + throw new Error('Notification context not found'); + } + const notification = { data: { message: `Duplicated` } }; + notificationContext.peek('positive', notification); + } + + return { error }; + } +} + +export { UmbDuplicateElementRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/element-duplicate.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/element-duplicate.server.data-source.ts new file mode 100644 index 000000000000..f63b361147d3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/element-duplicate.server.data-source.ts @@ -0,0 +1,42 @@ +import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbDuplicateToDataSource, UmbDuplicateToRequestArgs } from '@umbraco-cms/backoffice/tree'; + +/** + * Duplicate Element Server Data Source + * @class UmbDuplicateElementServerDataSource + */ +export class UmbDuplicateElementServerDataSource implements UmbDuplicateToDataSource { + #host: UmbControllerHost; + + /** + * Creates an instance of UmbDuplicateElementServerDataSource. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbDuplicateElementServerDataSource + */ + constructor(host: UmbControllerHost) { + this.#host = host; + } + + /** + * Duplicate an item for the given id to the destination unique + * @param {UmbDuplicateToRequestArgs} args + * @returns {*} + * @memberof UmbDuplicateElementServerDataSource + */ + async duplicateTo(args: UmbDuplicateToRequestArgs) { + if (!args.unique) throw new Error('Unique is missing'); + if (args.destination.unique === undefined) throw new Error('Destination unique is missing'); + + return tryExecute( + this.#host, + ElementService.postElementByIdCopy({ + path: { id: args.unique }, + body: { + target: args.destination.unique ? { id: args.destination.unique } : null, + }, + }), + ); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/index.ts new file mode 100644 index 000000000000..720210d64c0f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/index.ts @@ -0,0 +1,2 @@ +export { UmbDuplicateElementRepository } from './element-duplicate.repository.js'; +export { UMB_DUPLICATE_ELEMENT_REPOSITORY_ALIAS } from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/manifests.ts new file mode 100644 index 000000000000..de88a33b6371 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/duplicate/repository/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_DUPLICATE_ELEMENT_REPOSITORY_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_DUPLICATE_ELEMENT_REPOSITORY_ALIAS, + name: 'Duplicate Element Repository', + api: () => import('./element-duplicate.repository.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/manifests.ts new file mode 100644 index 000000000000..143536a338e3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/manifests.ts @@ -0,0 +1,11 @@ +import { manifests as createManifests } from './create/manifests.js'; +import { manifests as duplicateManifests } from './duplicate/manifests.js'; +import { manifests as moveManifests } from './move/manifests.js'; +import { manifests as reloadManifests } from './reload/manifests.js'; + +export const manifests: Array = [ + ...createManifests, + ...duplicateManifests, + ...moveManifests, + ...reloadManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/index.ts new file mode 100644 index 000000000000..74a3a50ea708 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/index.ts @@ -0,0 +1 @@ +export { UmbMoveElementRepository } from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/manifests.ts new file mode 100644 index 000000000000..1284d04aa956 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/manifests.ts @@ -0,0 +1,33 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_TREE_ALIAS, UMB_ELEMENT_TREE_REPOSITORY_ALIAS } from '../../tree/index.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_MOVE, +} from '../../user-permissions/constants.js'; +import { UMB_MOVE_ELEMENT_REPOSITORY_ALIAS } from './repository/index.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'entityAction', + kind: 'moveTo', + alias: 'Umb.EntityAction.Element.MoveTo', + name: 'Move Element Entity Action', + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + treeRepositoryAlias: UMB_ELEMENT_TREE_REPOSITORY_ALIAS, + moveRepositoryAlias: UMB_MOVE_ELEMENT_REPOSITORY_ALIAS, + treeAlias: UMB_ELEMENT_TREE_ALIAS, + foldersOnly: true, + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_MOVE], + }, + { alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS }, + ], + }, + ...repositoryManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/constants.ts new file mode 100644 index 000000000000..47c89f093dcc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_MOVE_ELEMENT_REPOSITORY_ALIAS = 'Umb.Repository.Element.Move'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/element-move.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/element-move.repository.ts new file mode 100644 index 000000000000..c5e21ad0d6bb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/element-move.repository.ts @@ -0,0 +1,25 @@ +import { UmbMoveElementServerDataSource } from './element-move.server.data-source.js'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import type { UmbMoveRepository, UmbMoveToRequestArgs } from '@umbraco-cms/backoffice/tree'; + +export class UmbMoveElementRepository extends UmbRepositoryBase implements UmbMoveRepository { + #moveSource = new UmbMoveElementServerDataSource(this); + + async requestMoveTo(args: UmbMoveToRequestArgs) { + const { error } = await this.#moveSource.moveTo(args); + + if (!error) { + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + if (!notificationContext) { + throw new Error('Notification context not found'); + } + const notification = { data: { message: `Moved` } }; + notificationContext.peek('positive', notification); + } + + return { error }; + } +} + +export { UmbMoveElementRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/element-move.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/element-move.server.data-source.ts new file mode 100644 index 000000000000..a4d3c415bdeb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/element-move.server.data-source.ts @@ -0,0 +1,42 @@ +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbMoveDataSource, UmbMoveToRequestArgs } from '@umbraco-cms/backoffice/tree'; + +/** + * Move Element Server Data Source + * @class UmbMoveElementServerDataSource + */ +export class UmbMoveElementServerDataSource implements UmbMoveDataSource { + #host: UmbControllerHost; + + /** + * Creates an instance of UmbMoveElementServerDataSource. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbMoveElementServerDataSource + */ + constructor(host: UmbControllerHost) { + this.#host = host; + } + + /** + * Move an item for the given id to the target unique + * @param {UmbMoveToRequestArgs} args - The move to request arguments + * @returns {Promise} The result of the move operation + * @memberof UmbMoveElementServerDataSource + */ + async moveTo(args: UmbMoveToRequestArgs) { + if (!args.unique) throw new Error('Unique is missing'); + if (args.destination.unique === undefined) throw new Error('Destination unique is missing'); + + return tryExecute( + this.#host, + ElementService.putElementByIdMove({ + path: { id: args.unique }, + body: { + target: args.destination.unique ? { id: args.destination.unique } : null, + }, + }), + ); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/index.ts new file mode 100644 index 000000000000..a9ffd3fa0798 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/index.ts @@ -0,0 +1,2 @@ +export { UmbMoveElementRepository } from './element-move.repository.js'; +export { UMB_MOVE_ELEMENT_REPOSITORY_ALIAS } from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/manifests.ts new file mode 100644 index 000000000000..e7f6c62c20b4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/move/repository/manifests.ts @@ -0,0 +1,11 @@ +import { UMB_MOVE_ELEMENT_REPOSITORY_ALIAS } from './constants.js'; +import type { ManifestRepository } from '@umbraco-cms/backoffice/extension-registry'; + +const moveRepository: ManifestRepository = { + type: 'repository', + alias: UMB_MOVE_ELEMENT_REPOSITORY_ALIAS, + name: 'Move Element Repository', + api: () => import('./element-move.repository.js'), +}; + +export const manifests = [moveRepository]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/reload/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/reload/manifests.ts new file mode 100644 index 000000000000..2f8b80eab919 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-actions/reload/manifests.ts @@ -0,0 +1,11 @@ +import { UMB_ELEMENT_ROOT_ENTITY_TYPE, UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; + +export const manifests: Array = [ + { + type: 'entityAction', + kind: 'reloadTreeItemChildren', + alias: 'Umb.EntityAction.Element.Tree.ReloadChildrenOf', + name: 'Reload Element Tree Item Children Entity Action', + forEntityTypes: [UMB_ELEMENT_ROOT_ENTITY_TYPE, UMB_ELEMENT_FOLDER_ENTITY_TYPE], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/manifests.ts new file mode 100644 index 000000000000..397a31367809 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as moveToManifests } from './move-to/manifests.js'; + +export const manifests: Array = [...moveToManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/manifests.ts new file mode 100644 index 000000000000..4ead85777a23 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/manifests.ts @@ -0,0 +1,40 @@ +import { UMB_ELEMENT_COLLECTION_ALIAS } from '../../collection/constants.js'; +import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_TREE_ALIAS } from '../../tree/constants.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_MOVE, +} from '../../user-permissions/constants.js'; +import { UMB_BULK_MOVE_ELEMENT_REPOSITORY_ALIAS } from './repository/constants.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; + +export const manifests: Array = [ + { + type: 'entityBulkAction', + kind: 'moveTo', + alias: 'Umb.EntityBulkAction.Element.MoveTo', + name: 'Move Element Entity Bulk Action', + weight: 20, + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + bulkMoveRepositoryAlias: UMB_BULK_MOVE_ELEMENT_REPOSITORY_ALIAS, + treeAlias: UMB_ELEMENT_TREE_ALIAS, + }, + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_ELEMENT_COLLECTION_ALIAS, + }, + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_MOVE], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], + }, + ...repositoryManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/repository/constants.ts new file mode 100644 index 000000000000..18acf5fa4faf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_BULK_MOVE_ELEMENT_REPOSITORY_ALIAS = 'Umb.Repository.Element.BulkMove'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/repository/manifests.ts new file mode 100644 index 000000000000..dde4e20f00dc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/repository/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_BULK_MOVE_ELEMENT_REPOSITORY_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_BULK_MOVE_ELEMENT_REPOSITORY_ALIAS, + name: 'Bulk Move Element Repository', + api: () => import('./move-to.repository.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/repository/move-to.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/repository/move-to.repository.ts new file mode 100644 index 000000000000..52905765a4a3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity-bulk-actions/move-to/repository/move-to.repository.ts @@ -0,0 +1,35 @@ +import { UmbMoveElementServerDataSource } from '../../../entity-actions/move/repository/element-move.server.data-source.js'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import type { UmbBulkMoveToRepository, UmbBulkMoveToRequestArgs } from '@umbraco-cms/backoffice/entity-bulk-action'; +import type { UmbRepositoryErrorResponse } from '@umbraco-cms/backoffice/repository'; + +export class UmbBulkMoveToElementRepository extends UmbRepositoryBase implements UmbBulkMoveToRepository { + #moveSource = new UmbMoveElementServerDataSource(this); + + async requestBulkMoveTo(args: UmbBulkMoveToRequestArgs): Promise { + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + let count = 0; + + const destination = args.destination; + for (const unique of args.uniques) { + const { error } = await this.#moveSource.moveTo({ unique, destination }); + + if (error) { + const notification = { data: { message: error.message } }; + notificationContext?.peek('danger', notification); + } else { + count++; + } + } + + if (count > 0) { + const notification = { data: { message: `Moved ${count} ${count === 1 ? 'element' : 'elements'}` } }; + notificationContext?.peek('positive', notification); + } + + return {}; + } +} + +export { UmbBulkMoveToElementRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/entity.ts new file mode 100644 index 000000000000..b15864925a92 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/entity.ts @@ -0,0 +1,11 @@ +export const UMB_ELEMENT_ENTITY_TYPE = 'element'; +export const UMB_ELEMENT_ROOT_ENTITY_TYPE = 'element-root'; +export const UMB_ELEMENT_FOLDER_ENTITY_TYPE = 'element-folder'; + +export type UmbElementEntityType = typeof UMB_ELEMENT_ENTITY_TYPE; +export type UmbElementRootEntityType = typeof UMB_ELEMENT_ROOT_ENTITY_TYPE; +export type UmbElementFolderEntityType = typeof UMB_ELEMENT_FOLDER_ENTITY_TYPE; +export type UmbElementEntityTypeUnion = UmbElementEntityType | UmbElementRootEntityType | UmbElementFolderEntityType; + +export const UMB_ELEMENT_PROPERTY_VALUE_ENTITY_TYPE = `${UMB_ELEMENT_ENTITY_TYPE}-property-value`; +export type UmbElementPropertyValueEntityType = typeof UMB_ELEMENT_PROPERTY_VALUE_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/constants.ts new file mode 100644 index 000000000000..7072c998ae6a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/constants.ts @@ -0,0 +1,2 @@ +export * from './repository/constants.js'; +export * from './workspace/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/manifests.ts new file mode 100644 index 000000000000..d6a3d53abea7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/manifests.ts @@ -0,0 +1,44 @@ +import { UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS } from '../repository/constants.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_DELETE, +} from '../../user-permissions/constants.js'; +import { manifests as moveManifests } from './move/manifests.js'; +import { + UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS, +} from '@umbraco-cms/backoffice/recycle-bin'; + +const folderDelete: UmbExtensionManifest = { + type: 'entityAction', + kind: 'folderDelete', + alias: 'Umb.EntityAction.Element.Folder.Delete', + name: 'Delete Element Folder Entity Action', + forEntityTypes: [UMB_ELEMENT_FOLDER_ENTITY_TYPE], + meta: { + icon: 'icon-trash-empty', + folderRepositoryAlias: UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS, // TODO: [LK] This needs to call the recycle-bin repository instead. + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_DELETE], + }, + { alias: UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS }, + ], +}; + +const folderUpdate: UmbExtensionManifest = { + type: 'entityAction', + kind: 'folderUpdate', + alias: 'Umb.EntityAction.Element.Folder.Rename', + name: 'Rename Element Folder Entity Action', + forEntityTypes: [UMB_ELEMENT_FOLDER_ENTITY_TYPE], + meta: { + folderRepositoryAlias: UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS, + }, + conditions: [{ alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS }], +}; + +export const manifests: Array = [folderDelete, folderUpdate, ...moveManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/index.ts new file mode 100644 index 000000000000..aff2457b4c72 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/index.ts @@ -0,0 +1 @@ +export { UmbMoveElementFolderRepository } from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/manifests.ts new file mode 100644 index 000000000000..2901c451c8f4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/manifests.ts @@ -0,0 +1,33 @@ +import { UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../../entity.js'; +import { UMB_ELEMENT_TREE_ALIAS, UMB_ELEMENT_TREE_REPOSITORY_ALIAS } from '../../../tree/index.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_MOVE, +} from '../../../user-permissions/constants.js'; +import { UMB_MOVE_ELEMENT_FOLDER_REPOSITORY_ALIAS } from './repository/index.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'entityAction', + kind: 'moveTo', + alias: 'Umb.EntityAction.Element.Folder.MoveTo', + name: 'Move Element Folder Entity Action', + forEntityTypes: [UMB_ELEMENT_FOLDER_ENTITY_TYPE], + meta: { + treeRepositoryAlias: UMB_ELEMENT_TREE_REPOSITORY_ALIAS, + moveRepositoryAlias: UMB_MOVE_ELEMENT_FOLDER_REPOSITORY_ALIAS, + treeAlias: UMB_ELEMENT_TREE_ALIAS, + foldersOnly: true, + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_MOVE], + }, + { alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS }, + ], + }, + ...repositoryManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/constants.ts new file mode 100644 index 000000000000..024aa31f452b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_MOVE_ELEMENT_FOLDER_REPOSITORY_ALIAS = 'Umb.Repository.Element.Folder.Move'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/element-folder-move.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/element-folder-move.repository.ts new file mode 100644 index 000000000000..955b5548c74f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/element-folder-move.repository.ts @@ -0,0 +1,25 @@ +import { UmbMoveElementFolderServerDataSource } from './element-folder-move.server.data-source.js'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import type { UmbMoveRepository, UmbMoveToRequestArgs } from '@umbraco-cms/backoffice/tree'; + +export class UmbMoveElementFolderRepository extends UmbRepositoryBase implements UmbMoveRepository { + #moveSource = new UmbMoveElementFolderServerDataSource(this); + + async requestMoveTo(args: UmbMoveToRequestArgs) { + const { error } = await this.#moveSource.moveTo(args); + + if (!error) { + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + if (!notificationContext) { + throw new Error('Notification context not found'); + } + const notification = { data: { message: `Moved` } }; + notificationContext.peek('positive', notification); + } + + return { error }; + } +} + +export { UmbMoveElementFolderRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/element-folder-move.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/element-folder-move.server.data-source.ts new file mode 100644 index 000000000000..f8a6a152bf9b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/element-folder-move.server.data-source.ts @@ -0,0 +1,42 @@ +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbMoveDataSource, UmbMoveToRequestArgs } from '@umbraco-cms/backoffice/tree'; + +/** + * Move Element Folder Server Data Source + * @class UmbMoveElementFolderServerDataSource + */ +export class UmbMoveElementFolderServerDataSource implements UmbMoveDataSource { + #host: UmbControllerHost; + + /** + * Creates an instance of UmbMoveElementFolderServerDataSource. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbMoveElementFolderServerDataSource + */ + constructor(host: UmbControllerHost) { + this.#host = host; + } + + /** + * Move an item for the given id to the target unique + * @param {UmbMoveToRequestArgs} args - The move to request arguments + * @returns {Promise} The result of the move operation + * @memberof UmbMoveElementFolderServerDataSource + */ + async moveTo(args: UmbMoveToRequestArgs) { + if (!args.unique) throw new Error('Unique is missing'); + if (args.destination.unique === undefined) throw new Error('Destination unique is missing'); + + return tryExecute( + this.#host, + ElementService.putElementFolderByIdMove({ + path: { id: args.unique }, + body: { + target: args.destination.unique ? { id: args.destination.unique } : null, + }, + }), + ); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/index.ts new file mode 100644 index 000000000000..3d1ed7ecd90f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/index.ts @@ -0,0 +1,2 @@ +export { UmbMoveElementFolderRepository } from './element-folder-move.repository.js'; +export { UMB_MOVE_ELEMENT_FOLDER_REPOSITORY_ALIAS } from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/manifests.ts new file mode 100644 index 000000000000..b167ef8eb82d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/entity-actions/move/repository/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_MOVE_ELEMENT_FOLDER_REPOSITORY_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_MOVE_ELEMENT_FOLDER_REPOSITORY_ALIAS, + name: 'Move Element Folder Repository', + api: () => import('./element-folder-move.repository.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/index.ts new file mode 100644 index 000000000000..3d76f338dddc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/index.ts @@ -0,0 +1 @@ +export * from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/manifests.ts new file mode 100644 index 000000000000..a4399cb984ce --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/manifests.ts @@ -0,0 +1,11 @@ +import { manifests as entityActionsManifests } from './entity-actions/manifests.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as treeManifests } from './tree/manifests.js'; +import { manifests as workspaceManifests } from './workspace/manifests.js'; + +export const manifests: Array = [ + ...entityActionsManifests, + ...repositoryManifests, + ...treeManifests, + ...workspaceManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/constants.ts new file mode 100644 index 000000000000..078418fe1c1f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/constants.ts @@ -0,0 +1,3 @@ +export const UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS = 'Umb.Repository.Element.Folder'; +export const UMB_ELEMENT_FOLDER_STORE_ALIAS = 'Umb.Store.Element.Folder'; +export { UMB_ELEMENT_FOLDER_STORE_CONTEXT } from './element-folder.store.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.repository.ts new file mode 100644 index 000000000000..5d8e13f94257 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.repository.ts @@ -0,0 +1,16 @@ +import type { UmbElementFolderModel } from '../types.js'; +import { UmbElementFolderServerDataSource } from './element-folder.server.data-source.js'; +import { UMB_ELEMENT_FOLDER_STORE_CONTEXT } from './element-folder.store.context-token.js'; +import { UmbDetailRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbElementFolderRepository extends UmbDetailRepositoryBase< + UmbElementFolderModel, + UmbElementFolderServerDataSource +> { + constructor(host: UmbControllerHost) { + super(host, UmbElementFolderServerDataSource, UMB_ELEMENT_FOLDER_STORE_CONTEXT); + } +} + +export { UmbElementFolderRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.server.data-source.ts new file mode 100644 index 000000000000..7821432563f0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.server.data-source.ts @@ -0,0 +1,143 @@ +import { UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; +import type { UmbElementFolderModel } from '../types.js'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbId } from '@umbraco-cms/backoffice/id'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbDetailDataSource } from '@umbraco-cms/backoffice/repository'; +import type { UmbFolderModel } from '@umbraco-cms/backoffice/tree'; + +/** + * A data source for a Element folder that fetches data from the server + * @class UmbElementFolderServerDataSource + * @implements {UmbDetailDataSource} + */ +export class UmbElementFolderServerDataSource implements UmbDetailDataSource { + #host: UmbControllerHost; + + /** + * Creates an instance of UmbElementFolderServerDataSource. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbElementFolderServerDataSource + */ + constructor(host: UmbControllerHost) { + this.#host = host; + } + + /** + * Creates a scaffold for a Element folder + * @returns {*} + * @memberof UmbElementFolderServerDataSource + */ + async createScaffold() { + const scaffold: UmbElementFolderModel = { + entityType: UMB_ELEMENT_FOLDER_ENTITY_TYPE, + unique: UmbId.new(), + name: '', + }; + + return { data: scaffold }; + } + + /** + * Fetches a Element folder from the server + * @param {string} unique + * @returns {*} + * @memberof UmbElementFolderServerDataSource + */ + async read(unique: string) { + if (!unique) throw new Error('Unique is missing'); + + const { data, error } = await tryExecute( + this.#host, + ElementService.getElementFolderById({ + path: { id: unique }, + }), + ); + + if (data) { + const mappedData = { + entityType: UMB_ELEMENT_FOLDER_ENTITY_TYPE, + unique: data.id, + name: data.name, + isTrashed: data.isTrashed, + }; + + return { data: mappedData }; + } + + return { error }; + } + + /** + * Creates a Element folder on the server + * @param {UmbCreateFolderModel} model + * @returns {*} + * @memberof UmbElementFolderServerDataSource + */ + async create(model: UmbFolderModel, parentUnique: string | null) { + if (!model) throw new Error('Model is missing'); + if (!model.unique) throw new Error('Unique is missing'); + if (!model.name) throw new Error('Name is missing'); + + const body = { + id: model.unique, + parent: parentUnique ? { id: parentUnique } : null, + name: model.name, + }; + + const { error } = await tryExecute( + this.#host, + ElementService.postElementFolder({ + body, + }), + ); + + if (!error) { + return this.read(model.unique); + } + + return { error }; + } + + /** + * Updates a Element folder on the server + * @param {UmbFolderModel} model + * @returns {*} + * @memberof UmbElementFolderServerDataSource + */ + async update(model: UmbFolderModel) { + if (!model.unique) throw new Error('Unique is missing'); + if (!model.name) throw new Error('Folder name is missing'); + + const { error } = await tryExecute( + this.#host, + ElementService.putElementFolderById({ + path: { id: model.unique }, + body: { name: model.name }, + }), + ); + + if (!error) { + return this.read(model.unique); + } + + return { error }; + } + + /** + * Deletes a Element folder on the server + * @param {string} unique + * @returns {*} + * @memberof UmbElementFolderServerDataSource + */ + async delete(unique: string) { + if (!unique) throw new Error('Unique is missing'); + return tryExecute( + this.#host, + ElementService.deleteElementFolderById({ + path: { id: unique }, + }), + ); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.store.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.store.context-token.ts new file mode 100644 index 000000000000..384c3258ba79 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.store.context-token.ts @@ -0,0 +1,4 @@ +import type { UmbElementFolderStore } from './element-folder.store.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_ELEMENT_FOLDER_STORE_CONTEXT = new UmbContextToken('UmbElementFolderStore'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.store.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.store.ts new file mode 100644 index 000000000000..533bf96fcf9c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/element-folder.store.ts @@ -0,0 +1,22 @@ +import { UMB_ELEMENT_FOLDER_STORE_CONTEXT } from './element-folder.store.context-token.js'; +import { UmbDetailStoreBase } from '@umbraco-cms/backoffice/store'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbFolderModel } from '@umbraco-cms/backoffice/tree'; + +/** + * @class UmbElementFolderStore + * @augments {UmbStoreBase} + * @description - Data Store for Element Folders + */ +export class UmbElementFolderStore extends UmbDetailStoreBase { + /** + * Creates an instance of UmbElementFolderStore. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbElementFolderStore + */ + constructor(host: UmbControllerHost) { + super(host, UMB_ELEMENT_FOLDER_STORE_CONTEXT.toString()); + } +} + +export { UmbElementFolderStore as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/index.ts new file mode 100644 index 000000000000..2fa76acb3956 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/index.ts @@ -0,0 +1,3 @@ +export * from './constants.js'; +export * from './element-folder.repository.js'; +export * from './item/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/constants.ts new file mode 100644 index 000000000000..8f4fa6f27f09 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/constants.ts @@ -0,0 +1,4 @@ +export { UMB_ELEMENT_FOLDER_ITEM_STORE_CONTEXT } from './element-folder-item.store.context-token.js'; + +export const UMB_ELEMENT_FOLDER_ITEM_REPOSITORY_ALIAS = 'Umb.Repository.ElementFolderItem'; +export const UMB_ELEMENT_FOLDER_ITEM_STORE_ALIAS = 'Umb.Store.ElementFolderItem'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.repository.ts new file mode 100644 index 000000000000..e90d8b8c5e38 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.repository.ts @@ -0,0 +1,13 @@ +import { UmbElementFolderItemServerDataSource } from './element-folder-item.server.data-source.js'; +import { UMB_ELEMENT_FOLDER_ITEM_STORE_CONTEXT } from './element-folder-item.store.context-token.js'; +import type { UmbElementFolderItemModel } from './types.js'; +import { UmbItemRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbElementFolderItemRepository extends UmbItemRepositoryBase { + constructor(host: UmbControllerHost) { + super(host, UmbElementFolderItemServerDataSource, UMB_ELEMENT_FOLDER_ITEM_STORE_CONTEXT); + } +} + +export { UmbElementFolderItemRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.cache-invalidation.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.cache-invalidation.manager.ts new file mode 100644 index 000000000000..6d3ca7482028 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.cache-invalidation.manager.ts @@ -0,0 +1,13 @@ +import { elementFolderItemCache } from './element-folder-item.server.cache.js'; +import { UmbManagementApiItemDataCacheInvalidationManager } from '@umbraco-cms/backoffice/management-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { FolderItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; + +export class UmbManagementApiElementFolderItemDataCacheInvalidationManager extends UmbManagementApiItemDataCacheInvalidationManager { + constructor(host: UmbControllerHost) { + super(host, { + dataCache: elementFolderItemCache, + eventSources: ['Umbraco:CMS:ElementFolder'], + }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.cache.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.cache.ts new file mode 100644 index 000000000000..693b5ced5073 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.cache.ts @@ -0,0 +1,6 @@ +import type { FolderItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbManagementApiItemDataCache } from '@umbraco-cms/backoffice/management-api'; + +const elementFolderItemCache = new UmbManagementApiItemDataCache(); + +export { elementFolderItemCache }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.data-source.ts new file mode 100644 index 000000000000..61dfc2b7ac3e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.data-source.ts @@ -0,0 +1,46 @@ +import { UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../../entity.js'; +import { UmbManagementApiElementFolderItemDataRequestManager } from './element-folder-item.server.request-manager.js'; +import type { UmbElementFolderItemModel } from './types.js'; +import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository'; +import type { FolderItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +/** + * A data source for Element Folder items that fetches data from the server + * @class UmbElementFolderItemServerDataSource + */ +export class UmbElementFolderItemServerDataSource extends UmbItemServerDataSourceBase< + FolderItemResponseModel, + UmbElementFolderItemModel +> { + #itemRequestManager = new UmbManagementApiElementFolderItemDataRequestManager(this); + + /** + * Creates an instance of UmbElementFolderItemServerDataSource. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbElementFolderItemServerDataSource + */ + constructor(host: UmbControllerHost) { + super(host, { + mapper, + }); + } + + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const { data, error } = await this.#itemRequestManager.getItems(uniques); + + return { data: this._getMappedItems(data), error }; + } +} + +const mapper = (item: FolderItemResponseModel): UmbElementFolderItemModel => { + return { + entityType: UMB_ELEMENT_FOLDER_ENTITY_TYPE, + name: item.name, + unique: item.id, + icon: 'icon-folder', + flags: item.flags.map((flag) => ({ alias: flag.alias })), + }; +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.request-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.request-manager.ts new file mode 100644 index 000000000000..c3db9dac35e1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.server.request-manager.ts @@ -0,0 +1,15 @@ +/* eslint-disable local-rules/no-direct-api-import */ +import { elementFolderItemCache } from './element-folder-item.server.cache.js'; +import { ElementService, type FolderItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbManagementApiItemDataRequestManager } from '@umbraco-cms/backoffice/management-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbManagementApiElementFolderItemDataRequestManager extends UmbManagementApiItemDataRequestManager { + constructor(host: UmbControllerHost) { + super(host, { + getItems: (ids: Array) => ElementService.getItemElementFolder({ query: { id: ids } }), + dataCache: elementFolderItemCache, + getUniqueMethod: (item) => item.id, + }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.store.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.store.context-token.ts new file mode 100644 index 000000000000..37b8e5ad47b7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.store.context-token.ts @@ -0,0 +1,6 @@ +import type { UmbElementFolderItemStore } from './element-folder-item.store.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_ELEMENT_FOLDER_ITEM_STORE_CONTEXT = new UmbContextToken( + 'UmbElementFolderItemStore', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.store.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.store.ts new file mode 100644 index 000000000000..1513d153e986 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/element-folder-item.store.ts @@ -0,0 +1,23 @@ +import type { UmbElementFolderItemModel } from './types.js'; +import { UMB_ELEMENT_FOLDER_ITEM_STORE_CONTEXT } from './element-folder-item.store.context-token.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemStoreBase } from '@umbraco-cms/backoffice/store'; + +/** + * @class UmbElementFolderItemStore + * @augments {UmbStoreBase} + * @description - Data Store for Element Folder items + */ + +export class UmbElementFolderItemStore extends UmbItemStoreBase { + /** + * Creates an instance of UmbElementFolderItemStore. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbElementFolderItemStore + */ + constructor(host: UmbControllerHost) { + super(host, UMB_ELEMENT_FOLDER_ITEM_STORE_CONTEXT.toString()); + } +} + +export { UmbElementFolderItemStore as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/index.ts new file mode 100644 index 000000000000..c2fdee79c68a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/index.ts @@ -0,0 +1,3 @@ +export { UmbElementFolderItemRepository } from './element-folder-item.repository.js'; +export { UMB_ELEMENT_FOLDER_ITEM_REPOSITORY_ALIAS } from './constants.js'; +export type { UmbElementFolderItemModel } from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/manifests.ts new file mode 100644 index 000000000000..f1b8b40f1c2f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/manifests.ts @@ -0,0 +1,17 @@ +import { UMB_ELEMENT_FOLDER_ITEM_REPOSITORY_ALIAS, UMB_ELEMENT_FOLDER_ITEM_STORE_ALIAS } from './constants.js'; +import { UmbElementFolderItemStore } from './element-folder-item.store.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_ELEMENT_FOLDER_ITEM_REPOSITORY_ALIAS, + name: 'Element Folder Item Repository', + api: () => import('./element-folder-item.repository.js'), + }, + { + type: 'itemStore', + alias: UMB_ELEMENT_FOLDER_ITEM_STORE_ALIAS, + name: 'Element Folder Item Store', + api: UmbElementFolderItemStore, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/types.ts new file mode 100644 index 000000000000..0ad6b22efb01 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/item/types.ts @@ -0,0 +1,10 @@ +import type { UmbElementFolderEntityType } from '../../../entity.js'; +import type { UmbEntityFlag, UmbEntityWithFlags } from '@umbraco-cms/backoffice/entity-flag'; + +export interface UmbElementFolderItemModel extends UmbEntityWithFlags { + entityType: UmbElementFolderEntityType; + name: string; + unique: string; + icon: string; + flags: Array; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/manifests.ts new file mode 100644 index 000000000000..d7ae6173cf26 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/repository/manifests.ts @@ -0,0 +1,18 @@ +import { UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS, UMB_ELEMENT_FOLDER_STORE_ALIAS } from './constants.js'; +import { manifests as itemManifests } from './item/manifests.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS, + name: 'Element Folder Repository', + api: () => import('./element-folder.repository.js'), + }, + { + type: 'store', + alias: UMB_ELEMENT_FOLDER_STORE_ALIAS, + name: 'Element Folder Store', + api: () => import('./element-folder.store.js'), + }, + ...itemManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/tree/element-folder-tree-item.context.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/tree/element-folder-tree-item.context.ts new file mode 100644 index 000000000000..273ea4968bcf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/tree/element-folder-tree-item.context.ts @@ -0,0 +1,26 @@ +import type { UmbElementTreeRootModel } from '../../tree/types.js'; +import type { UmbElementFolderTreeItemModel } from '../types.js'; +import { UmbDefaultTreeItemContext } from '@umbraco-cms/backoffice/tree'; +import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbElementFolderTreeItemContext extends UmbDefaultTreeItemContext< + UmbElementFolderTreeItemModel, + UmbElementTreeRootModel +> { + // TODO: Provide this together with the EntityContext, ideally this takes part via a extension-type [NL] + #isTrashedContext = new UmbIsTrashedEntityContext(this); + + // TODO: Move to API + readonly isTrashed = this._treeItem.asObservablePart((item) => item?.isTrashed ?? false); + + constructor(host: UmbControllerHost) { + super(host); + + this.observe(this.isTrashed, (isTrashed) => { + this.#isTrashedContext.setIsTrashed(isTrashed); + }); + } +} + +export { UmbElementFolderTreeItemContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/tree/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/tree/manifests.ts new file mode 100644 index 000000000000..0b0317c0222c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/tree/manifests.ts @@ -0,0 +1,13 @@ +import { UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; +import type { ManifestTreeItem } from '@umbraco-cms/backoffice/tree'; + +const treeItem: ManifestTreeItem = { + type: 'treeItem', + kind: 'default', + alias: 'Umb.TreeItem.Element.Folder', + name: 'Element Folder Tree Item', + api: () => import('./element-folder-tree-item.context.js'), + forEntityTypes: [UMB_ELEMENT_FOLDER_ENTITY_TYPE], +}; + +export const manifests: Array = [treeItem]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/types.ts new file mode 100644 index 000000000000..83671ebbef66 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/types.ts @@ -0,0 +1,11 @@ +import type { UmbElementTreeItemModel } from '../tree/types.js'; +import type { UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../entity.js'; +import type { UmbFolderModel } from '@umbraco-cms/backoffice/tree'; + +export interface UmbElementFolderTreeItemModel extends UmbElementTreeItemModel { + entityType: typeof UMB_ELEMENT_FOLDER_ENTITY_TYPE; +} + +export interface UmbElementFolderModel extends UmbFolderModel { + isTrashed?: boolean; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/constants.ts new file mode 100644 index 000000000000..db7d5e6a2519 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/constants.ts @@ -0,0 +1,4 @@ +export const UMB_ELEMENT_FOLDER_WORKSPACE_ALIAS = 'Umb.Workspace.Element.Folder'; + +export * from './paths.js'; +export { UMB_ELEMENT_FOLDER_WORKSPACE_CONTEXT } from './element-folder.workspace.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/element-folder-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/element-folder-editor.element.ts new file mode 100644 index 000000000000..e167c7713082 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/element-folder-editor.element.ts @@ -0,0 +1,17 @@ +import { html, customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; + +@customElement('umb-element-folder-workspace-editor') +export class UmbElementFolderWorkspaceEditorElement extends UmbLitElement { + override render() { + return html``; + } +} + +export { UmbElementFolderWorkspaceEditorElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-folder-workspace-editor': UmbElementFolderWorkspaceEditorElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/element-folder-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/element-folder-workspace.context.ts new file mode 100644 index 000000000000..2eb2abdcb152 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/element-folder-workspace.context.ts @@ -0,0 +1,46 @@ +import { UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS, type UmbElementFolderRepository } from '../repository/index.js'; +import { UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; +import type { UmbElementFolderModel } from '../types.js'; +import { UMB_ELEMENT_FOLDER_WORKSPACE_ALIAS } from './constants.js'; +import { UmbElementFolderWorkspaceEditorElement } from './element-folder-editor.element.js'; +import { UmbEntityNamedDetailWorkspaceContextBase } from '@umbraco-cms/backoffice/workspace'; +import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin'; +import type { IRoutingInfo, PageComponent } from '@umbraco-cms/backoffice/router'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbRoutableWorkspaceContext, UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; + +export class UmbElementFolderWorkspaceContext + extends UmbEntityNamedDetailWorkspaceContextBase + implements UmbSubmittableWorkspaceContext, UmbRoutableWorkspaceContext +{ + readonly isTrashed = this._data.createObservablePartOfCurrent((data) => data?.isTrashed); + + #isTrashedContext = new UmbIsTrashedEntityContext(this); + + constructor(host: UmbControllerHost) { + super(host, { + workspaceAlias: UMB_ELEMENT_FOLDER_WORKSPACE_ALIAS, + entityType: UMB_ELEMENT_FOLDER_ENTITY_TYPE, + detailRepositoryAlias: UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS, + }); + + this.observe(this.isTrashed, (isTrashed) => this.#onTrashStateChange(isTrashed)); + + this.routes.setRoutes([ + { + path: 'edit/:unique', + component: UmbElementFolderWorkspaceEditorElement, + setup: (component: PageComponent, info: IRoutingInfo) => { + const unique = info.match.params.unique; + this.load(unique); + }, + }, + ]); + } + + #onTrashStateChange(isTrashed?: boolean) { + this.#isTrashedContext.setIsTrashed(isTrashed ?? false); + } +} + +export { UmbElementFolderWorkspaceContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/element-folder.workspace.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/element-folder.workspace.context-token.ts new file mode 100644 index 000000000000..25d6dc3cc852 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/element-folder.workspace.context-token.ts @@ -0,0 +1,14 @@ +import { UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; +import type { UmbElementFolderWorkspaceContext } from './element-folder-workspace.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; +import type { UmbWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; + +export const UMB_ELEMENT_FOLDER_WORKSPACE_CONTEXT = new UmbContextToken< + UmbWorkspaceContext, + UmbElementFolderWorkspaceContext +>( + 'UmbWorkspaceContext', + undefined, + (context): context is UmbElementFolderWorkspaceContext => + context.getEntityType?.() === UMB_ELEMENT_FOLDER_ENTITY_TYPE, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/manifests.ts new file mode 100644 index 000000000000..eff7ffd43ac7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/manifests.ts @@ -0,0 +1,73 @@ +import { UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_ROOT_WORKSPACE_ALIAS } from '../../workspace/element-root/constants.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_UPDATE, +} from '../../user-permissions/constants.js'; +import { UMB_ELEMENT_COLLECTION_ALIAS } from '../../collection/constants.js'; +import { UMB_ELEMENT_FOLDER_WORKSPACE_ALIAS } from './constants.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS, UmbSubmitWorkspaceAction } from '@umbraco-cms/backoffice/workspace'; +import type { ManifestWorkspaceAction, ManifestWorkspaceRoutableKind } from '@umbraco-cms/backoffice/workspace'; +import type { ManifestWorkspaceViewCollectionKind } from '@umbraco-cms/backoffice/collection'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +const workspace: ManifestWorkspaceRoutableKind = { + type: 'workspace', + kind: 'routable', + alias: UMB_ELEMENT_FOLDER_WORKSPACE_ALIAS, + name: 'Element Folder Workspace', + api: () => import('./element-folder-workspace.context.js'), + meta: { + entityType: UMB_ELEMENT_FOLDER_ENTITY_TYPE, + }, +}; + +const workspaceView: ManifestWorkspaceViewCollectionKind = { + type: 'workspaceView', + kind: 'collection', + alias: 'Umb.WorkspaceView.Element.Collection', + name: 'Element Collection Workspace View', + meta: { + label: 'Folder', + pathname: 'folder', + icon: 'icon-folder', + collectionAlias: UMB_ELEMENT_COLLECTION_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + oneOf: [UMB_ELEMENT_ROOT_WORKSPACE_ALIAS, UMB_ELEMENT_FOLDER_WORKSPACE_ALIAS], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], +}; + +const workspaceAction: ManifestWorkspaceAction = { + type: 'workspaceAction', + kind: 'default', + alias: 'Umb.WorkspaceAction.Element.Folder.Submit', + name: 'Submit Element Folder Workspace Action', + api: UmbSubmitWorkspaceAction, + meta: { + label: '#buttons_save', + look: 'primary', + color: 'positive', + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_FOLDER_WORKSPACE_ALIAS, + }, + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_UPDATE], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], +}; + +export const manifests: Array = [workspace, workspaceView, workspaceAction]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/paths.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/paths.ts new file mode 100644 index 000000000000..9c5365d2b654 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/folder/workspace/paths.ts @@ -0,0 +1,14 @@ +import { UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; +import { UmbPathPattern } from '@umbraco-cms/backoffice/router'; +import { UMB_LIBRARY_SECTION_PATHNAME } from '@umbraco-cms/backoffice/library'; +import { UMB_WORKSPACE_PATH_PATTERN } from '@umbraco-cms/backoffice/workspace'; + +export const UMB_ELEMENT_FOLDER_WORKSPACE_PATH = UMB_WORKSPACE_PATH_PATTERN.generateAbsolute({ + sectionName: UMB_LIBRARY_SECTION_PATHNAME, + entityType: UMB_ELEMENT_FOLDER_ENTITY_TYPE, +}); + +export const UMB_EDIT_ELEMENT_FOLDER_WORKSPACE_PATH_PATTERN = new UmbPathPattern<{ unique: string }>( + 'edit/:unique', + UMB_ELEMENT_FOLDER_WORKSPACE_PATH, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/index.ts new file mode 100644 index 000000000000..fe326f2c9eee --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/index.ts @@ -0,0 +1,7 @@ +export * from './collection/index.js'; +export * from './folder/index.js'; +export * from './item/index.js'; +export * from './menu/index.js'; +export * from './reference/index.js'; +export * from './repository/index.js'; +export * from './tree/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/constants.ts new file mode 100644 index 000000000000..41a409dec1f0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/constants.ts @@ -0,0 +1 @@ +export * from './repository/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/data-resolver/element-item-data-resolver.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/data-resolver/element-item-data-resolver.ts new file mode 100644 index 000000000000..13a5bb69b5d1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/data-resolver/element-item-data-resolver.ts @@ -0,0 +1,327 @@ +import { UmbElementVariantState } from '../../types.js'; +import type { UmbElementItemModel } from '../types.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbEntityFlag } from '@umbraco-cms/backoffice/entity-flag'; +import { + UmbArrayState, + UmbBasicState, + UmbBooleanState, + UmbObjectState, + UmbStringState, + type Observable, +} from '@umbraco-cms/backoffice/observable-api'; +import { type UmbVariantContext, UMB_VARIANT_CONTEXT } from '@umbraco-cms/backoffice/variant'; +import type { UmbItemDataResolver } from '@umbraco-cms/backoffice/entity-item'; + +type UmbElementItemDataResolverModel = Omit; + +/** + * @param variants + * @returns {boolean} + */ +function isVariantsInvariant(variants: Array<{ culture: string | null }>): boolean { + return variants?.[0]?.culture === null; +} + +/** + * + * @param variants + * @param culture + * @returns {T | undefined} + */ +function findVariant(variants: Array, culture: string): T | undefined { + return variants.find((x) => x.culture === culture); +} + +/** + * A controller for resolving data for a element item + * @exports + * @class UmbElementItemDataResolver + * @augments {UmbControllerBase} + */ +export class UmbElementItemDataResolver + extends UmbControllerBase + implements UmbItemDataResolver +{ + #data = new UmbObjectState(undefined); + + public readonly entityType = this.#data.asObservablePart((x) => x?.entityType); + public readonly unique = this.#data.asObservablePart((x) => x?.unique); + public readonly icon = this.#data.asObservablePart((x) => x?.documentType.icon); + public readonly typeUnique = this.#data.asObservablePart((x) => x?.documentType.unique); + public readonly isTrashed = this.#data.asObservablePart((x) => x?.isTrashed); + public readonly hasCollection = this.#data.asObservablePart((x) => !!x?.documentType.collection); + + #name = new UmbStringState(undefined); + public readonly name = this.#name.asObservable(); + + #state = new UmbStringState(undefined); + public readonly state = this.#state.asObservable() as Observable; + + #isDraft = new UmbBooleanState(undefined); + public readonly isDraft = this.#isDraft.asObservable(); + + #createDate = new UmbBasicState(undefined); + public readonly createDate = this.#createDate.asObservable(); + + #updateDate = new UmbBasicState(undefined); + public readonly updateDate = this.#updateDate.asObservable(); + + #flags = new UmbArrayState([], (data) => data.alias); + public readonly flags = this.#flags.asObservable(); + + #variantContext?: UmbVariantContext; + #fallbackCulture?: string | null; + #displayCulture?: string | null; + + constructor(host: UmbControllerHost) { + super(host); + + this.consumeContext(UMB_VARIANT_CONTEXT, (context) => { + this.#variantContext = context; + this.#observeVariantContext(); + }); + } + + #observeVariantContext() { + this.observe( + this.#variantContext?.displayCulture, + (displayCulture) => { + if (displayCulture === undefined) return; + this.#displayCulture = displayCulture; + this.#setVariantAwareValues(); + }, + 'umbObserveVariantId', + ); + + this.observe( + this.#variantContext?.fallbackCulture, + (fallbackCulture) => { + if (fallbackCulture === undefined) return; + this.#fallbackCulture = fallbackCulture; + this.#setVariantAwareValues(); + }, + 'umbObserveFallbackCulture', + ); + } + + /** + * Get the display culture or fallback culture + * @returns {string | null | undefined} The display culture or fallback culture + * @memberof UmbElementItemDataResolver + */ + getCulture(): string | null | undefined { + return this.#displayCulture || this.#fallbackCulture; + } + + /** + * Get the current item + * @returns {ElementItemModel | undefined} The current item + * @memberof UmbElementItemDataResolver + */ + getData(): ElementItemModel | undefined { + return this.#data.getValue(); + } + + /** + * Set the current item + * @param {ElementItemModel | undefined} data The current item + * @memberof UmbElementItemDataResolver + */ + setData(data: ElementItemModel | undefined) { + this.#data.setValue(data); + this.#setVariantAwareValues(); + } + + /** + * Get the entity type of the item + * @returns {Promise} The entity type of the item + * @memberof UmbElementItemDataResolver + */ + async getEntityType(): Promise { + return await this.observe(this.entityType).asPromise(); + } + + /** + * Get the unique of the item + * @returns {Promise} The unique of the item + * @memberof UmbElementItemDataResolver + */ + async getUnique(): Promise { + return await this.observe(this.unique).asPromise(); + } + + /** + * Get the name of the item + * @returns {Promise} The name of the item + * @memberof UmbElementItemDataResolver + */ + async getName(): Promise { + return (await this.observe(this.name).asPromise()) || ''; + } + + /** + * Get the icon of the item + * @returns {Promise} The icon of the item + * @memberof UmbElementItemDataResolver + */ + async getIcon(): Promise { + return await this.observe(this.icon).asPromise(); + } + + /** + * Get the state of the item + * @returns {Promise} The state of the item + * @memberof UmbElementItemDataResolver + */ + async getState(): Promise { + return await this.observe(this.state).asPromise(); + } + + /** + * Get the isDraft of the item + * @returns {Promise} The isDraft of the item + * @memberof UmbElementItemDataResolver + */ + async getIsDraft(): Promise { + return (await this.observe(this.isDraft).asPromise()) ?? false; + } + + /** + * Get the isTrashed of the item + * @returns {Promise} The isTrashed of the item + * @memberof UmbElementItemDataResolver + */ + async getIsTrashed(): Promise { + return (await this.observe(this.isTrashed).asPromise()) ?? false; + } + + /** + * Get the create date of the item + * @returns {Promise} The create date of the item + * @memberof UmbElementItemDataResolver + */ + async getCreateDate(): Promise { + return (await this.observe(this.createDate).asPromise()) || undefined; + } + + /** + * Get the update date of the item + * @returns {Promise} The update date of the item + * @memberof UmbElementItemDataResolver + */ + async getUpdateDate(): Promise { + return (await this.observe(this.updateDate).asPromise()) || undefined; + } + + /** + * Test if the item has a collection + * @returns {boolean} Boolean of whether the item has a collection. + * @memberof UmbElementItemDataResolver + */ + getHasCollection(): boolean { + return this.getData()?.documentType.collection != undefined; + } + + #setVariantAwareValues() { + if (!this.#variantContext) return; + if (!this.#displayCulture) return; + if (!this.#fallbackCulture) return; + if (!this.#data) return; + this.#setName(); + this.#setIsDraft(); + this.#setState(); + this.#setCreateDate(); + this.#setUpdateDate(); + this.#setFlags(); + } + + #setName() { + const variant = this.#getCurrentVariant(); + if (variant?.name) { + this.#name.setValue(variant.name); + return; + } + + const variants = this.getData()?.variants; + if (variants) { + // Try fallback culture first, then first variant with any name + const fallbackName = findVariant(variants, this.#fallbackCulture!)?.name ?? variants.find((x) => x.name)?.name; + + if (fallbackName) { + this.#name.setValue(`(${fallbackName})`); + return; + } + } + + this.#name.setValue('(Untitled)'); + } + + #setIsDraft() { + const variant = this.#getCurrentVariant(); + const isDraft = variant?.state === UmbElementVariantState.DRAFT || false; + this.#isDraft.setValue(isDraft); + } + + #setState() { + const variant = this.#getCurrentVariant(); + const state = variant?.state || UmbElementVariantState.NOT_CREATED; + this.#state.setValue(state); + } + + async #setCreateDate() { + const variant = await this.#getCurrentVariant(); + if (variant) { + this.#createDate.setValue(variant.createDate); + return; + } + + const variants = this.getData()?.variants; + if (variants) { + const fallbackCreateDate = findVariant(variants, this.#fallbackCulture!)?.createDate; + this.#createDate.setValue(fallbackCreateDate); + } else { + this.#createDate.setValue(undefined); + } + } + + async #setUpdateDate() { + const variant = await this.#getCurrentVariant(); + if (variant) { + this.#updateDate.setValue(variant.updateDate); + return; + } + + const variants = this.getData()?.variants; + if (variants) { + const fallbackUpdateDate = findVariant(variants, this.#fallbackCulture!)?.updateDate; + this.#updateDate.setValue(fallbackUpdateDate); + } else { + this.#updateDate.setValue(undefined); + } + } + + #setFlags() { + const data = this.getData(); + if (!data) { + this.#flags.setValue([]); + return; + } + + const flags = data.flags ?? []; + const variantFlags = this.#getCurrentVariant()?.flags ?? []; + this.#flags.setValue([...flags, ...variantFlags]); + } + + #getCurrentVariant() { + const variants = this.getData()?.variants; + if (!variants) return undefined; + + if (isVariantsInvariant(variants)) { + return variants[0]; + } + + return findVariant(variants, this.#displayCulture!); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/index.ts new file mode 100644 index 000000000000..fd536b101113 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/index.ts @@ -0,0 +1 @@ +export { UmbElementItemRepository } from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/item-ref/element-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/item-ref/element-item-ref.element.ts new file mode 100644 index 000000000000..9b37603a3da3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/item-ref/element-item-ref.element.ts @@ -0,0 +1,138 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; +import { UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN } from '../../paths.js'; +import { UmbElementItemDataResolver } from '../data-resolver/element-item-data-resolver.js'; +import type { UmbElementItemModel } 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 { UmbDeselectedEvent, UmbSelectedEvent } from '@umbraco-cms/backoffice/event'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; +import type { UUISelectableEvent } from '@umbraco-cms/backoffice/external/uui'; + +@customElement('umb-element-item-ref') +export class UmbElementItemRefElement extends UmbLitElement { + #item = new UmbElementItemDataResolver(this); + + @property({ type: Object }) + public set item(value: UmbElementItemModel | undefined) { + this.#item.setData(value); + } + public get item(): UmbElementItemModel | undefined { + return this.#item.getData(); + } + + @property({ type: Boolean, reflect: true }) + readonly = false; + + @property({ type: Boolean }) + standalone = false; + + @property({ type: Boolean, attribute: 'select-only', reflect: true }) + selectOnly = false; + + @property({ type: Boolean, reflect: true }) + selectable = false; + + @property({ type: Boolean, reflect: true }) + selected = false; + + @property({ type: Boolean, reflect: true }) + disabled = false; + + @state() + private _unique = ''; + + @state() + private _name = ''; + + @state() + private _icon = ''; + + @state() + private _isTrashed = false; + + @state() + private _isDraft = false; + + @state() + private _editPath = ''; + + constructor() { + super(); + + new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) + .addUniquePaths(['unique']) + .onSetup(() => { + return { data: { entityType: UMB_ELEMENT_ENTITY_TYPE, preset: {} } }; + }) + .observeRouteBuilder((routeBuilder) => { + this._editPath = routeBuilder({ entityType: UMB_ELEMENT_ENTITY_TYPE }); + }); + + this.#item.observe(this.#item.unique, (unique) => (this._unique = unique ?? '')); + this.#item.observe(this.#item.name, (name) => (this._name = name ?? '')); + this.#item.observe(this.#item.icon, (icon) => (this._icon = icon ?? '')); + this.#item.observe(this.#item.isTrashed, (isTrashed) => (this._isTrashed = isTrashed ?? false)); + this.#item.observe(this.#item.isDraft, (isDraft) => (this._isDraft = isDraft ?? false)); + } + + #getHref() { + if (!this._unique) return; + const path = UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this._unique }); + return this._editPath + path; + } + + #onSelected(event: UUISelectableEvent) { + event.stopPropagation(); + this.dispatchEvent(new UmbSelectedEvent(this._unique)); + } + + #onDeselected(event: UUISelectableEvent) { + event.stopPropagation(); + this.dispatchEvent(new UmbDeselectedEvent(this._unique)); + } + + override render() { + if (!this.item) return nothing; + + return html` + + + ${this.#renderIcon()}${this.#renderIsDraft()} ${this.#renderIsTrashed()} + + `; + } + + #renderIcon() { + if (!this._icon) return nothing; + return html``; + } + + #renderIsTrashed() { + if (!this._isTrashed) return nothing; + return html`Trashed`; + } + + #renderIsDraft() { + if (!this._isDraft) return nothing; + return html`Draft`; + } +} + +export { UmbElementItemRefElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-item-ref': UmbElementItemRefElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/item-ref/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/item-ref/manifests.ts new file mode 100644 index 000000000000..78ab937dfc95 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/item-ref/manifests.ts @@ -0,0 +1,11 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; + +export const manifests: Array = [ + { + type: 'entityItemRef', + alias: 'Umb.EntityItemRef.Element', + name: 'Element Entity Item Reference', + element: () => import('./element-item-ref.element.js'), + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/manifests.ts new file mode 100644 index 000000000000..e6a86d0b5c4e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/manifests.ts @@ -0,0 +1,4 @@ +import { manifests as itemRef } from './item-ref/manifests.js'; +import { manifests as repository } from './repository/manifests.js'; + +export const manifests: Array = [...itemRef, ...repository]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/constants.ts new file mode 100644 index 000000000000..a84109186bd4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/constants.ts @@ -0,0 +1,3 @@ +export const UMB_ELEMENT_ITEM_REPOSITORY_ALIAS = 'Umb.Repository.ElementItem'; +export const UMB_ELEMENT_STORE_ALIAS = 'Umb.Store.ElementItem'; +export { UMB_ELEMENT_ITEM_STORE_CONTEXT } from './element-item.store.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.repository.ts new file mode 100644 index 000000000000..7beac9126341 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.repository.ts @@ -0,0 +1,13 @@ +import { UmbElementItemServerDataSource } from './element-item.server.data-source.js'; +import { UMB_ELEMENT_ITEM_STORE_CONTEXT } from './element-item.store.context-token.js'; +import type { UmbElementItemModel } from './types.js'; +import { UmbItemRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbElementItemRepository extends UmbItemRepositoryBase { + constructor(host: UmbControllerHost) { + super(host, UmbElementItemServerDataSource, UMB_ELEMENT_ITEM_STORE_CONTEXT); + } +} + +export { UmbElementItemRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.cache-invalidation.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.cache-invalidation.manager.ts new file mode 100644 index 000000000000..e53301215432 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.cache-invalidation.manager.ts @@ -0,0 +1,42 @@ +import { elementItemCache } from './element-item.server.cache.js'; +import { UmbManagementApiItemDataCacheInvalidationManager } from '@umbraco-cms/backoffice/management-api'; +import type { ElementItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbManagementApiServerEventModel } from '@umbraco-cms/backoffice/management-api'; + +export class UmbManagementApiElementItemDataCacheInvalidationManager extends UmbManagementApiItemDataCacheInvalidationManager { + constructor(host: UmbControllerHost) { + super(host, { + dataCache: elementItemCache, + /* The element item model includes info about the element Type. + We need to invalidate the cache for both element and DocumentType events. */ + eventSources: ['Umbraco:CMS:Element', 'Umbraco:CMS:DocumentType'], + eventTypes: ['Updated', 'Deleted', 'Trashed'], + }); + } + + protected override _onServerEvent(event: UmbManagementApiServerEventModel) { + if (event.eventSource === 'Umbraco:CMS:DocumentType') { + this.#onElementTypeChange(event); + } else { + this.#onElementChange(event); + } + } + + #onElementChange(event: UmbManagementApiServerEventModel) { + // Invalidate the specific element + const elementId = event.key; + this._dataCache.delete(elementId); + } + + #onElementTypeChange(event: UmbManagementApiServerEventModel) { + // Invalidate all elements of the specified element Type + const elementTypeId = event.key; + const elementIds = this._dataCache + .getAll() + .filter((cachedItem) => cachedItem.documentType.id === elementTypeId) + .map((item) => item.id); + + elementIds.forEach((id) => this._dataCache.delete(id)); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.cache.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.cache.ts new file mode 100644 index 000000000000..b1c2bf60688d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.cache.ts @@ -0,0 +1,6 @@ +import type { ElementItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbManagementApiItemDataCache } from '@umbraco-cms/backoffice/management-api'; + +const elementItemCache = new UmbManagementApiItemDataCache(); + +export { elementItemCache }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.data-source.ts new file mode 100644 index 000000000000..bac16ef6ca77 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.data-source.ts @@ -0,0 +1,64 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; +import { UmbManagementApiElementItemDataRequestManager } from './element-item.server.request-manager.js'; +import type { UmbElementItemModel } from './types.js'; +import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository'; +import type { ElementItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +/** + * A data source for Element items that fetches data from the server + * @class UmbElementItemServerDataSource + * @implements {ElementTreeDataSource} + */ +export class UmbElementItemServerDataSource extends UmbItemServerDataSourceBase< + ElementItemResponseModel, + UmbElementItemModel +> { + #itemRequestManager = new UmbManagementApiElementItemDataRequestManager(this); + + /** + * Creates an instance of UmbElementItemServerDataSource. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbElementItemServerDataSource + */ + constructor(host: UmbControllerHost) { + super(host, { + mapper, + }); + } + + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const { data, error } = await this.#itemRequestManager.getItems(uniques); + + return { data: this._getMappedItems(data), error }; + } +} + +const mapper = (item: ElementItemResponseModel): UmbElementItemModel => { + return { + documentType: { + unique: item.documentType.id, + icon: item.documentType.icon, + collection: null, + }, + entityType: UMB_ELEMENT_ENTITY_TYPE, + hasChildren: item.hasChildren, + isTrashed: false, //item.isTrashed, + parent: item.parent ? { unique: item.parent.id } : null, + unique: item.id, + variants: item.variants.map((variant) => { + return { + culture: variant.culture || null, + name: variant.name, + state: variant.state, + flags: [], //variant.flags, + // TODO: [v17] Implement dates when available in the API. [LK] + //createDate: new Date(variant.createDate), + //updateDate: new Date(variant.updateDate), + }; + }), + flags: item.flags, + }; +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.request-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.request-manager.ts new file mode 100644 index 000000000000..bfc9e91f3177 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.server.request-manager.ts @@ -0,0 +1,15 @@ +/* eslint-disable local-rules/no-direct-api-import */ +import { elementItemCache } from './element-item.server.cache.js'; +import { ElementService, type ElementItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbManagementApiItemDataRequestManager } from '@umbraco-cms/backoffice/management-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbManagementApiElementItemDataRequestManager extends UmbManagementApiItemDataRequestManager { + constructor(host: UmbControllerHost) { + super(host, { + getItems: (ids: Array) => ElementService.getItemElement({ query: { id: ids } }), + dataCache: elementItemCache, + getUniqueMethod: (item) => item.id, + }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.store.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.store.context-token.ts new file mode 100644 index 000000000000..bb94ce9fce56 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.store.context-token.ts @@ -0,0 +1,4 @@ +import type { UmbElementItemStore } from './element-item.store.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_ELEMENT_ITEM_STORE_CONTEXT = new UmbContextToken('UmbElementItemStore'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.store.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.store.ts new file mode 100644 index 000000000000..e9a7a706781e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/element-item.store.ts @@ -0,0 +1,23 @@ +import type { UmbElementDetailModel } from '../../types.js'; +import { UMB_ELEMENT_ITEM_STORE_CONTEXT } from './element-item.store.context-token.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemStoreBase } from '@umbraco-cms/backoffice/store'; + +/** + * @class UmbElementItemStore + * @augments {UmbStoreBase} + * @description - Data Store for Element items + */ + +export class UmbElementItemStore extends UmbItemStoreBase { + /** + * Creates an instance of UmbElementItemStore. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbElementItemStore + */ + constructor(host: UmbControllerHost) { + super(host, UMB_ELEMENT_ITEM_STORE_CONTEXT.toString()); + } +} + +export { UmbElementItemStore as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/index.ts new file mode 100644 index 000000000000..08e4ec99d951 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/index.ts @@ -0,0 +1 @@ +export { UmbElementItemRepository } from './element-item.repository.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/manifests.ts new file mode 100644 index 000000000000..05b3dc1f716e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/manifests.ts @@ -0,0 +1,17 @@ +import { UMB_ELEMENT_ITEM_REPOSITORY_ALIAS, UMB_ELEMENT_STORE_ALIAS } from './constants.js'; +import { UmbElementItemStore } from './element-item.store.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_ELEMENT_ITEM_REPOSITORY_ALIAS, + name: 'Element Item Repository', + api: () => import('./element-item.repository.js'), + }, + { + type: 'itemStore', + alias: UMB_ELEMENT_STORE_ALIAS, + name: 'Element Item Store', + api: UmbElementItemStore, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/types.ts new file mode 100644 index 000000000000..44df6cbf6b7d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/repository/types.ts @@ -0,0 +1,28 @@ +import type { UmbElementEntityType } from '../../entity.js'; +import type { UmbElementVariantState } from '../../types.js'; +import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import type { UmbEntityFlag, UmbEntityWithFlags } from '@umbraco-cms/backoffice/entity-flag'; +import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; + +export interface UmbElementItemModel extends UmbEntityWithFlags { + documentType: { + unique: string; + icon: string; + collection?: UmbReferenceByUnique | null; + }; + entityType: UmbElementEntityType; + hasChildren: boolean; + isTrashed: boolean; + parent: { unique: UmbEntityUnique } | null; // TODO: Use UmbReferenceByUnique when it support unique as null + unique: string; + variants: Array; +} + +export interface UmbElementItemVariantModel { + name: string; + culture: string | null; + createDate?: Date; + updateDate?: Date; + state?: UmbElementVariantState | null; + flags: Array; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/item/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/item/types.ts new file mode 100644 index 000000000000..e32ac4b889fe --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/item/types.ts @@ -0,0 +1 @@ +export type * from './repository/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/manifests.ts new file mode 100644 index 000000000000..ea01e4085369 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/manifests.ts @@ -0,0 +1,31 @@ +import { manifests as collectionManifests } from './collection/manifests.js'; +import { manifests as entityActionManifests } from './entity-actions/manifests.js'; +import { manifests as entityBulkActionManifests } from './entity-bulk-actions/manifests.js'; +import { manifests as folderManifests } from './folder/manifests.js'; +import { manifests as itemManifests } from './item/manifests.js'; +import { manifests as menuManifests } from './menu/manifests.js'; +import { manifests as propertyEditorManifests } from './property-editor/manifests.js'; +import { manifests as publishingManifests } from './publishing/manifests.js'; +import { manifests as recycleBinManifests } from './recycle-bin/manifests.js'; +import { manifests as referenceManifests } from './reference/manifests.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as treeManifests } from './tree/manifests.js'; +import { manifests as workspaceManifests } from './workspace/manifests.js'; +import { manifests as userPermissionsManifests } from './user-permissions/manifests.js'; + +export const manifests: Array = [ + ...collectionManifests, + ...entityActionManifests, + ...entityBulkActionManifests, + ...folderManifests, + ...itemManifests, + ...menuManifests, + ...propertyEditorManifests, + ...publishingManifests, + ...recycleBinManifests, + ...referenceManifests, + ...repositoryManifests, + ...treeManifests, + ...workspaceManifests, + ...userPermissionsManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/menu/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/menu/constants.ts new file mode 100644 index 000000000000..393ae8bda641 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/menu/constants.ts @@ -0,0 +1,2 @@ +export const UMB_ELEMENT_MENU_ALIAS = 'Umb.Menu.Element'; +export const UMB_ELEMENT_MENU_ITEM_ALIAS = 'Umb.MenuItem.Element'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/menu/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/menu/index.ts new file mode 100644 index 000000000000..4f07201dcf0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/menu/index.ts @@ -0,0 +1 @@ +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/menu/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/menu/manifests.ts new file mode 100644 index 000000000000..e7da23e34cc3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/menu/manifests.ts @@ -0,0 +1,48 @@ +import { UMB_ELEMENT_TREE_ALIAS } from '../tree/constants.js'; +import { UMB_ELEMENT_ROOT_ENTITY_TYPE } from '../entity.js'; +import { UMB_ELEMENT_MENU_ALIAS, UMB_ELEMENT_MENU_ITEM_ALIAS } from './constants.js'; +import { UMB_LIBRARY_SECTION_ALIAS } from '@umbraco-cms/backoffice/library'; +import { UMB_SECTION_ALIAS_CONDITION_ALIAS } from '@umbraco-cms/backoffice/section'; +import type { ManifestMenu, ManifestSectionSidebarAppMenuWithEntityActionsKind } from '@umbraco-cms/backoffice/menu'; +import type { ManifestMenuItemTreeKind } from '@umbraco-cms/backoffice/tree'; + +const menu: ManifestMenu = { + type: 'menu', + alias: UMB_ELEMENT_MENU_ALIAS, + name: 'Element Menu', +}; + +const menuItem: ManifestMenuItemTreeKind = { + type: 'menuItem', + kind: 'tree', + alias: UMB_ELEMENT_MENU_ITEM_ALIAS, + name: 'Element Menu Item', + weight: 200, + meta: { + label: '#general_elements', + menus: [UMB_ELEMENT_MENU_ALIAS], + treeAlias: UMB_ELEMENT_TREE_ALIAS, + hideTreeRoot: true, + }, +}; + +const sectionSidebarApp: ManifestSectionSidebarAppMenuWithEntityActionsKind = { + type: 'sectionSidebarApp', + kind: 'menuWithEntityActions', + alias: 'Umb.SidebarMenu.Element', + name: 'Element Sidebar Menu', + weight: 100, + meta: { + label: '#general_elements', + menu: UMB_ELEMENT_MENU_ALIAS, + entityType: UMB_ELEMENT_ROOT_ENTITY_TYPE, + }, + conditions: [ + { + alias: UMB_SECTION_ALIAS_CONDITION_ALIAS, + match: UMB_LIBRARY_SECTION_ALIAS, + }, + ], +}; + +export const manifests: Array = [menu, menuItem, sectionSidebarApp]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/modals/element-picker-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/modals/element-picker-modal.token.ts new file mode 100644 index 000000000000..b104e5dbb5d4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/modals/element-picker-modal.token.ts @@ -0,0 +1,21 @@ +import { UMB_ELEMENT_TREE_ALIAS } from '../tree/constants.js'; +import type { UmbElementTreeItemModel } from '../tree/types.js'; +import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; +import { UMB_TREE_PICKER_MODAL_ALIAS } from '@umbraco-cms/backoffice/tree'; +import type { UmbTreePickerModalValue, UmbTreePickerModalData } from '@umbraco-cms/backoffice/tree'; + +export type UmbElementPickerModalData = UmbTreePickerModalData; +export type UmbElementPickerModalValue = UmbTreePickerModalValue; + +export const UMB_ELEMENT_PICKER_MODAL = new UmbModalToken( + UMB_TREE_PICKER_MODAL_ALIAS, + { + modal: { + type: 'sidebar', + size: 'small', + }, + data: { + treeAlias: UMB_ELEMENT_TREE_ALIAS, + }, + }, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/modals/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/modals/index.ts new file mode 100644 index 000000000000..615e0f87ffac --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/modals/index.ts @@ -0,0 +1,2 @@ +export * from './shared/index.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/modals/shared/element-variant-language-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/modals/shared/element-variant-language-picker.element.ts new file mode 100644 index 000000000000..bfdc26c43793 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/modals/shared/element-variant-language-picker.element.ts @@ -0,0 +1,194 @@ +import { UmbElementVariantState, type UmbElementVariantOptionModel } from '../../types.js'; +import type { UUIBooleanInputElement } from '@umbraco-cms/backoffice/external/uui'; +import { + css, + customElement, + html, + nothing, + property, + repeat, + state, + type PropertyValues, +} from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import type { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; + +@customElement('umb-element-variant-language-picker') +export class UmbElementVariantLanguagePickerElement extends UmbLitElement { + #selectionManager!: UmbSelectionManager; + + @property({ type: Array, attribute: false }) + variantLanguageOptions: Array = []; + + @property({ attribute: false }) + set selectionManager(value: UmbSelectionManager) { + this.#selectionManager = value; + this.observe( + this.selectionManager.selection, + (selection) => { + this._selection = selection; + this._isAllSelected = this.#isAllSelected(); + }, + '_selectionManager', + ); + } + get selectionManager() { + return this.#selectionManager; + } + + @state() + private _selection: Array = []; + + @state() + private _isAllSelected: boolean = false; + + /** + * A filter function that determines if an item is pickable or not. + * @memberof UmbElementVariantLanguagePickerElement + * @returns {boolean} - True if the item is pickable, false otherwise. + */ + @property({ attribute: false }) + public pickableFilter?: (item: UmbElementVariantOptionModel) => boolean; + + /** + * A filter function that determines if an item should be highlighted as a must select. + * @memberof UmbElementVariantLanguagePickerElement + * @returns {boolean} - True if the item is required, false otherwise. + */ + @property({ attribute: false }) + public requiredFilter?: (item: UmbElementVariantOptionModel) => boolean; + + protected override updated(_changedProperties: PropertyValues): void { + super.updated(_changedProperties); + + if (_changedProperties.has('variantLanguageOptions')) { + this._isAllSelected = this.#isAllSelected(); + } + + if (this.selectionManager && this.pickableFilter) { + this.#selectionManager.setAllowLimitation((unique) => { + const option = this.variantLanguageOptions.find((o) => o.unique === unique); + return option ? this.pickableFilter!(option) : true; + }); + } + } + + #onSelectAllChange(event: Event) { + const allUniques = this.variantLanguageOptions.map((o) => o.unique); + const filter = this.selectionManager.getAllowLimitation(); + const allowedUniques = allUniques.filter((unique) => filter(unique)); + + if ((event.target as UUIBooleanInputElement).checked) { + this.selectionManager.setSelection(allowedUniques); + } else { + this.selectionManager.setSelection([]); + } + } + + #isAllSelected() { + const allUniques = this.variantLanguageOptions.map((o) => o.unique); + const filter = this.selectionManager.getAllowLimitation(); + const allowedUniques = allUniques.filter((unique) => filter(unique)); + return this._selection.length !== 0 && this._selection.length === allowedUniques.length; + } + + override render() { + if (this.variantLanguageOptions.length === 0) { + return html` + There are no available variants + `; + } + + return html` + + ${repeat( + this.variantLanguageOptions, + (option) => option.unique, + (option) => html` ${this.#renderItem(option)} `, + )} + `; + } + + #renderItem(option: UmbElementVariantOptionModel) { + const pickable = this.pickableFilter ? this.pickableFilter(option) : true; + const selected = this._selection.includes(option.unique); + const mustSelect = (!selected && this.requiredFilter?.(option)) ?? false; + return html` + this.selectionManager.select(option.unique)} + @deselected=${() => this.selectionManager.deselect(option.unique)} + ?selected=${selected}> + + ${UmbElementVariantLanguagePickerElement.renderLabel(option, mustSelect)} + + `; + } + + static renderLabel(option: UmbElementVariantOptionModel, mustSelect?: boolean) { + return html`
+ ${option.language.name} +
${UmbElementVariantLanguagePickerElement.renderVariantStatus(option)}
+ ${option.language.isMandatory && mustSelect + ? html`
+ Mandatory language +
` + : nothing} +
`; + } + + static renderVariantStatus(option: UmbElementVariantOptionModel) { + switch (option.variant?.state) { + case UmbElementVariantState.PUBLISHED: + return html`Published`; + case UmbElementVariantState.PUBLISHED_PENDING_CHANGES: + return html`Published with pending changes`; + case UmbElementVariantState.DRAFT: + return html`Draft`; + case UmbElementVariantState.NOT_CREATED: + default: + return html`Not created`; + } + } + + static override styles = [ + UmbTextStyles, + css` + .required { + color: var(--uui-color-danger); + --uui-menu-item-color-hover: var(--uui-color-danger-emphasis); + --uui-menu-item-color-disabled: var(--uui-color-danger); + } + .label { + padding: var(--uui-size-space-3) 0; + } + .label-status { + font-size: var(--uui-type-small-size); + } + + uui-menu-item { + --uui-menu-item-flat-structure: 1; + --uui-menu-item-border-radius: var(--uui-border-radius); + } + + uui-checkbox { + margin-bottom: var(--uui-size-space-3); + } + `, + ]; +} + +export default UmbElementVariantLanguagePickerElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-variant-language-picker': UmbElementVariantLanguagePickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/modals/shared/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/modals/shared/index.ts new file mode 100644 index 000000000000..6ba0331c1095 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/modals/shared/index.ts @@ -0,0 +1 @@ +export * from './element-variant-language-picker.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/modals/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/modals/types.ts new file mode 100644 index 000000000000..c88b3bd079fc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/modals/types.ts @@ -0,0 +1,5 @@ +import type { UmbElementVariantOptionModel } from '../types.js'; +import type { UmbContentVariantPickerData, UmbContentVariantPickerValue } from '@umbraco-cms/backoffice/content'; + +export type UmbElementVariantPickerData = UmbContentVariantPickerData; +export type UmbElementVariantPickerValue = UmbContentVariantPickerValue; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/package.json b/src/Umbraco.Web.UI.Client/src/packages/elements/package.json new file mode 100644 index 000000000000..9192fb75e039 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/element", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/paths.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/paths.ts new file mode 100644 index 000000000000..75399988be61 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/paths.ts @@ -0,0 +1,21 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from './entity.js'; +import type { UmbElementEntityTypeUnion } from './entity.js'; +import { UmbPathPattern } from '@umbraco-cms/backoffice/router'; +import { UMB_WORKSPACE_PATH_PATTERN } from '@umbraco-cms/backoffice/workspace'; +import { UMB_LIBRARY_SECTION_PATHNAME } from '@umbraco-cms/backoffice/library'; + +export const UMB_ELEMENT_WORKSPACE_PATH = UMB_WORKSPACE_PATH_PATTERN.generateAbsolute({ + sectionName: UMB_LIBRARY_SECTION_PATHNAME, + entityType: UMB_ELEMENT_ENTITY_TYPE, +}); + +export const UMB_CREATE_ELEMENT_WORKSPACE_PATH_PATTERN = new UmbPathPattern<{ + parentEntityType: UmbElementEntityTypeUnion; + parentUnique?: string | null; + documentTypeUnique: string; +}>('create/parent/:parentEntityType/:parentUnique/:documentTypeUnique', UMB_ELEMENT_WORKSPACE_PATH); + +export const UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN = new UmbPathPattern<{ unique: string }>( + 'edit/:unique', + UMB_ELEMENT_WORKSPACE_PATH, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/Umbraco.ElementPicker.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/Umbraco.ElementPicker.ts new file mode 100644 index 000000000000..886bf3171fdf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/Umbraco.ElementPicker.ts @@ -0,0 +1,10 @@ +import type { ManifestPropertyEditorSchema } from '@umbraco-cms/backoffice/property-editor'; + +export const manifest: ManifestPropertyEditorSchema = { + type: 'propertyEditorSchema', + name: 'Element Picker', + alias: 'Umbraco.ElementPicker', + meta: { + defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.ElementPicker', + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-folder-tree-data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-folder-tree-data-source.ts new file mode 100644 index 000000000000..51ade0c11cde --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-folder-tree-data-source.ts @@ -0,0 +1,35 @@ +import { UmbElementTreeRepository } from '../../tree/element-tree.repository.js'; +import { UmbElementFolderItemRepository } from '../../folder/repository/item/element-folder-item.repository.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbPickerTreeDataSource } from '@umbraco-cms/backoffice/picker-data-source'; +import type { + UmbTreeAncestorsOfRequestArgs, + UmbTreeChildrenOfRequestArgs, + UmbTreeItemModel, + UmbTreeRootItemsRequestArgs, +} from '@umbraco-cms/backoffice/tree'; + +export class UmbElementFolderTreePropertyEditorDataSource extends UmbControllerBase implements UmbPickerTreeDataSource { + #item = new UmbElementFolderItemRepository(this); + #tree = new UmbElementTreeRepository(this); + + requestTreeRoot = () => this.#tree.requestTreeRoot(); + + requestTreeRootItems = (args: UmbTreeRootItemsRequestArgs) => { + args.foldersOnly = true; + return this.#tree.requestTreeRootItems(args); + }; + + requestTreeItemsOf = (args: UmbTreeChildrenOfRequestArgs) => { + args.foldersOnly = true; + return this.#tree.requestTreeItemsOf(args); + }; + + requestTreeItemAncestors = (args: UmbTreeAncestorsOfRequestArgs) => this.#tree.requestTreeItemAncestors(args); + + requestItems = (uniques: Array) => this.#item.requestItems(uniques); + + treePickableFilter: (treeItem: UmbTreeItemModel) => boolean = (treeItem) => treeItem.isFolder; +} + +export { UmbElementFolderTreePropertyEditorDataSource as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-picker-property-editor-ui.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-picker-property-editor-ui.element.ts new file mode 100644 index 000000000000..5d1e2b36ec09 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-picker-property-editor-ui.element.ts @@ -0,0 +1,98 @@ +import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbFormControlMixin, UMB_VALIDATION_EMPTY_LOCALIZATION_KEY } from '@umbraco-cms/backoffice/validation'; +import type { UmbInputEntityDataElement } from '@umbraco-cms/backoffice/entity-data-picker'; +import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; +import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor'; + +import '@umbraco-cms/backoffice/entity-data-picker'; + +@customElement('umb-element-picker-property-editor-ui') +export class UmbElementPickerPropertyEditorUIElement + extends UmbFormControlMixin | undefined, typeof UmbLitElement>(UmbLitElement, undefined) + implements UmbPropertyEditorUiElement +{ + #dataSourceAlias = 'Umb.PropertyEditorDataSource.Element'; + + @property({ type: Boolean }) + mandatory?: boolean; + + @property({ type: String }) + mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY; + + @property({ type: String }) + name?: string; + + @property({ type: Boolean, reflect: true }) + readonly = false; + + public set config(config: UmbPropertyEditorUiElement['config'] | undefined) { + if (!config) return; + + const minMax = config?.getValueByAlias('validationLimit'); + this._min = minMax?.min ?? 0; + this._max = minMax?.max ?? Infinity; + + this._minMessage = `${this.localize.term('validation_minCount')} ${this._min} ${this.localize.term('validation_items')}`; + this._maxMessage = `${this.localize.term('validation_maxCount')} ${this._max} ${this.localize.term('validation_itemsSelected')}`; + } + + @state() + private _min = 0; + + @state() + private _minMessage = ''; + + @state() + private _max = Infinity; + + @state() + private _maxMessage = ''; + + override focus() { + return this.shadowRoot?.querySelector('umb-input-entity-data')?.focus(); + } + + override firstUpdated(changedProperties: Map) { + super.firstUpdated(changedProperties); + + this.addFormControlElement(this.shadowRoot!.querySelector('umb-input-entity-data')!); + + if (this._min && this._max && this._min > this._max) { + console.warn( + `Property (Element Picker) has been misconfigured, 'min' is greater than 'max'. Please correct your data type configuration.`, + this, + ); + } + } + + #onChange(event: CustomEvent & { target: UmbInputEntityDataElement }) { + this.value = event.target.selection; + this.dispatchEvent(new UmbChangeEvent()); + } + + override render() { + return html` + + + `; + } +} + +export { UmbElementPickerPropertyEditorUIElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-picker-property-editor-ui': UmbElementPickerPropertyEditorUIElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-tree-data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-tree-data-source.ts new file mode 100644 index 000000000000..eb9811be9067 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/element-tree-data-source.ts @@ -0,0 +1,29 @@ +import { UmbElementTreeRepository } from '../../tree/element-tree.repository.js'; +import { UmbElementItemRepository } from '../../item/repository/element-item.repository.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbPickerTreeDataSource } from '@umbraco-cms/backoffice/picker-data-source'; +import type { + UmbTreeAncestorsOfRequestArgs, + UmbTreeChildrenOfRequestArgs, + UmbTreeItemModel, + UmbTreeRootItemsRequestArgs, +} from '@umbraco-cms/backoffice/tree'; + +export class UmbElementTreePropertyEditorDataSource extends UmbControllerBase implements UmbPickerTreeDataSource { + #item = new UmbElementItemRepository(this); + #tree = new UmbElementTreeRepository(this); + + requestTreeRoot = () => this.#tree.requestTreeRoot(); + + requestTreeRootItems = (args: UmbTreeRootItemsRequestArgs) => this.#tree.requestTreeRootItems(args); + + requestTreeItemsOf = (args: UmbTreeChildrenOfRequestArgs) => this.#tree.requestTreeItemsOf(args); + + requestTreeItemAncestors = (args: UmbTreeAncestorsOfRequestArgs) => this.#tree.requestTreeItemAncestors(args); + + requestItems = (uniques: Array) => this.#item.requestItems(uniques); + + treePickableFilter: (treeItem: UmbTreeItemModel) => boolean = (treeItem) => !treeItem.isFolder; +} + +export { UmbElementTreePropertyEditorDataSource as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/manifests.ts new file mode 100644 index 000000000000..074480be0852 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/element-picker/manifests.ts @@ -0,0 +1,58 @@ +import { manifest as schemaManifest } from './Umbraco.ElementPicker.js'; +import { UMB_PICKER_DATA_SOURCE_TYPE } from '@umbraco-cms/backoffice/picker-data-source'; +import type { ManifestPropertyEditorDataSource } from '@umbraco-cms/backoffice/property-editor-data-source'; +import type { ManifestPropertyEditorUi } from '@umbraco-cms/backoffice/property-editor'; + +const dataSources: Array = [ + { + type: 'propertyEditorDataSource', + alias: 'Umb.PropertyEditorDataSource.Element', + dataSourceType: UMB_PICKER_DATA_SOURCE_TYPE, + name: 'Element Property Data Source', + api: () => import('./element-tree-data-source.js'), + meta: { + label: 'Elements', + description: 'Umbraco Elements data source for property editors.', + icon: 'icon-plugin', + }, + }, + { + type: 'propertyEditorDataSource', + alias: 'Umb.PropertyEditorDataSource.ElementFolder', + dataSourceType: UMB_PICKER_DATA_SOURCE_TYPE, + name: 'Element Folder Property Data Source', + api: () => import('./element-folder-tree-data-source.js'), + meta: { + label: 'Element Folders', + description: 'Umbraco Element Folders data source for property editors.', + icon: 'icon-folder', + }, + }, +]; + +const propertyEditorUi: ManifestPropertyEditorUi = { + type: 'propertyEditorUi', + alias: 'Umb.PropertyEditorUi.ElementPicker', + name: 'Element Picker Property Editor UI', + element: () => import('./element-picker-property-editor-ui.element.js'), + meta: { + label: schemaManifest.name, + propertyEditorSchemaAlias: schemaManifest.alias, + icon: 'icon-plugin', + group: 'pickers', + supportsReadOnly: true, + settings: { + properties: [ + { + alias: 'validationLimit', + label: 'Amount', + propertyEditorUiAlias: 'Umb.PropertyEditorUi.NumberRange', + config: [{ alias: 'validationRange', value: { min: 0, max: Infinity } }], + weight: 100, + }, + ], + }, + }, +}; + +export const manifests: Array = [...dataSources, propertyEditorUi, schemaManifest]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/manifests.ts new file mode 100644 index 000000000000..5a53cbdb3792 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/property-editor/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as elementPicker } from './element-picker/manifests.js'; + +export const manifests: Array = [...elementPicker]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/constants.ts new file mode 100644 index 000000000000..a9c47536f828 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/constants.ts @@ -0,0 +1,5 @@ +export * from './publish/constants.js'; +export * from './unpublish/constants.js'; +export * from './schedule-publish/constants.js'; +export * from './workspace-context/constants.js'; +export * from './repository/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/index.ts new file mode 100644 index 000000000000..1600e01168d9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/index.ts @@ -0,0 +1,3 @@ +export * from './constants.js'; +export * from './repository/index.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/manifests.ts new file mode 100644 index 000000000000..4cf1dc6d6593 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/manifests.ts @@ -0,0 +1,13 @@ +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as workspaceContextManifests } from './workspace-context/manifests.js'; +import { manifests as publishManifests } from './publish/manifests.js'; +import { manifests as unpublishManifests } from './unpublish/manifests.js'; +import { manifests as schedulePublishManifests } from './schedule-publish/manifests.js'; + +export const manifests: Array = [ + ...repositoryManifests, + ...workspaceContextManifests, + ...publishManifests, + ...unpublishManifests, + ...schedulePublishManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/constants.ts new file mode 100644 index 000000000000..26f4f0dd5f39 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/constants.ts @@ -0,0 +1 @@ +export * from './modal/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-action/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-action/index.ts new file mode 100644 index 000000000000..91e19bc73546 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-action/index.ts @@ -0,0 +1 @@ +export * from './publish.action.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-action/manifests.ts new file mode 100644 index 000000000000..e77b2757b962 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-action/manifests.ts @@ -0,0 +1,32 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../../entity.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_PUBLISH, +} from '../../../user-permissions/constants.js'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'entityAction', + kind: 'default', + alias: 'Umb.EntityAction.Element.Publish', + name: 'Publish Element Entity Action', + weight: 510, + api: () => import('./publish.action.js'), + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + icon: 'icon-globe', + label: '#actions_publish', + additionalOptions: true, + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_PUBLISH], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-action/publish.action.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-action/publish.action.ts new file mode 100644 index 000000000000..7fef61702785 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-action/publish.action.ts @@ -0,0 +1,132 @@ +import type { UmbElementVariantOptionModel } from '../../../types.js'; +import { UMB_ELEMENT_PUBLISH_MODAL } from '../modal/constants.js'; +import { UmbElementDetailRepository } from '../../../repository/index.js'; +import { UmbElementPublishingRepository } from '../../repository/index.js'; +import { UMB_APP_LANGUAGE_CONTEXT, UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language'; +import type { UmbEntityActionArgs } from '@umbraco-cms/backoffice/entity-action'; +import { UmbEntityActionBase, UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; + +export class UmbPublishElementEntityAction extends UmbEntityActionBase { + constructor(host: UmbControllerHost, args: UmbEntityActionArgs) { + super(host, args); + } + + override async execute() { + if (!this.args.unique) throw new Error('The element unique identifier is missing'); + + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + const localize = new UmbLocalizationController(this); + + const languageRepository = new UmbLanguageCollectionRepository(this._host); + const { data: languageData } = await languageRepository.requestCollection({}); + + const elementRepository = new UmbElementDetailRepository(this._host); + const { data: elementData } = await elementRepository.requestByUnique(this.args.unique); + + if (!elementData) throw new Error('The element was not found'); + + const appLanguageContext = await this.getContext(UMB_APP_LANGUAGE_CONTEXT); + if (!appLanguageContext) throw new Error('The app language context is missing'); + const appCulture = appLanguageContext.getAppCulture(); + + const options: Array = elementData.variants + // only display culture variants as options + .filter((variant) => variant.segment === null) + .map((variant) => ({ + culture: variant.culture, + segment: variant.segment, + language: languageData?.items.find((language) => language.unique === variant.culture) ?? { + name: appCulture!, + entityType: 'language', + fallbackIsoCode: null, + isDefault: true, + isMandatory: false, + unique: appCulture!, + }, + variant, + unique: new UmbVariantId(variant.culture, variant.segment).toString(), + })); + + // Figure out the default selections + const selection: Array = []; + // If the app language is one of the options, select it by default: + if (appCulture && options.some((o) => o.unique === appCulture)) { + selection.push(new UmbVariantId(appCulture, null).toString()); + } else if (options.length > 0) { + // If not, select the first option by default: + selection.push(options[0].unique); + } + + const result = await umbOpenModal(this, UMB_ELEMENT_PUBLISH_MODAL, { + data: { + confirmLabel: '#actions_publish', + options, + }, + value: { selection }, + }).catch(() => undefined); + + if (!result?.selection.length) return; + + const variantIds = result?.selection.map((x) => UmbVariantId.FromString(x)) ?? []; + + // find all segments of a selected culture + const publishableVariantIds = variantIds.flatMap((variantId) => + elementData.variants + .filter((variant) => variantId.culture === variant.culture) + .map((variant) => UmbVariantId.Create(variant).toSegment(variant.segment)), + ); + + if (publishableVariantIds.length) { + const publishingRepository = new UmbElementPublishingRepository(this._host); + const { error } = await publishingRepository.publish( + this.args.unique, + publishableVariantIds.map((variantId) => ({ variantId })), + ); + + if (error) { + throw error; + } + + // If the content is invariant, we need to show a different notification + const isInvariant = options.length === 1 && options[0].culture === null; + + if (isInvariant) { + notificationContext?.peek('positive', { + data: { + headline: localize.term('speechBubbles_editContentPublishedHeader'), + message: localize.term('speechBubbles_editContentPublishedText'), + }, + }); + } else { + const elementVariants = elementData.variants.filter((variant) => + result.selection.includes(variant.culture!), + ); + notificationContext?.peek('positive', { + data: { + headline: localize.term('speechBubbles_editContentPublishedHeader'), + message: localize.term( + 'speechBubbles_editVariantPublishedText', + localize.list(elementVariants.map((v) => UmbVariantId.Create(v).toString() ?? v.name)), + ), + }, + }); + } + + const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); + const event = new UmbRequestReloadStructureForEntityEvent({ + unique: this.args.unique, + entityType: this.args.entityType, + }); + + actionEventContext?.dispatchEvent(event); + } + } +} + +export default UmbPublishElementEntityAction; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-bulk-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-bulk-action/manifests.ts new file mode 100644 index 000000000000..ee40f679354c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-bulk-action/manifests.ts @@ -0,0 +1,38 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../../entity.js'; +import { UMB_ELEMENT_COLLECTION_ALIAS } from '../../../collection/constants.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_PUBLISH, + UMB_USER_PERMISSION_ELEMENT_UPDATE, +} from '../../../user-permissions/constants.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'entityBulkAction', + kind: 'default', + alias: 'Umb.EntityBulkAction.Element.Publish', + name: 'Publish Element Entity Bulk Action', + weight: 50, + api: () => import('./publish.bulk-action.js'), + meta: { + icon: 'icon-globe', + label: '#actions_publish', + }, + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_ELEMENT_COLLECTION_ALIAS, + }, + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_UPDATE, UMB_USER_PERMISSION_ELEMENT_PUBLISH], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-bulk-action/publish.bulk-action.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-bulk-action/publish.bulk-action.ts new file mode 100644 index 000000000000..e2e8021467d1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/entity-bulk-action/publish.bulk-action.ts @@ -0,0 +1,163 @@ +import { UmbElementPublishingRepository } from '../../repository/index.js'; +import type { UmbElementVariantOptionModel } from '../../../types.js'; +import { UMB_ELEMENT_PUBLISH_MODAL } from '../modal/constants.js'; +import { UMB_ELEMENT_ENTITY_TYPE } from '../../../entity.js'; +import { UmbPublishElementEntityAction } from '../entity-action/index.js'; +import { UmbEntityBulkActionBase } from '@umbraco-cms/backoffice/entity-bulk-action'; +import { UMB_APP_LANGUAGE_CONTEXT, UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { umbConfirmModal, umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; +import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; +import { UmbRequestReloadChildrenOfEntityEvent } from '@umbraco-cms/backoffice/entity-action'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; + +export class UmbElementPublishEntityBulkAction extends UmbEntityBulkActionBase { + async execute() { + const entityContext = await this.getContext(UMB_ENTITY_CONTEXT); + if (!entityContext) { + throw new Error('Entity context not found'); + } + const entityType = entityContext.getEntityType(); + const unique = entityContext.getUnique(); + + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + const localize = new UmbLocalizationController(this); + + if (!entityType) throw new Error('Entity type not found'); + if (unique === undefined) throw new Error('Entity unique not found'); + + // If there is only one selection, we can refer to the regular publish entity action: + if (this.selection.length === 1) { + const action = new UmbPublishElementEntityAction(this._host, { + unique: this.selection[0], + entityType: UMB_ELEMENT_ENTITY_TYPE, + meta: {} as never, + }); + await action.execute(); + return; + } + + const languageRepository = new UmbLanguageCollectionRepository(this._host); + const { data: languageData } = await languageRepository.requestCollection({}); + + const options: UmbElementVariantOptionModel[] = (languageData?.items ?? []).map((language) => ({ + language, + variant: { + name: language.name, + culture: language.unique, + state: null, + createDate: null, + publishDate: null, + updateDate: null, + segment: null, + scheduledPublishDate: null, + scheduledUnpublishDate: null, + flags: [], + }, + unique: new UmbVariantId(language.unique, null).toString(), + culture: language.unique, + segment: null, + })); + + const eventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); + if (!eventContext) { + throw new Error('Event context not found'); + } + const event = new UmbRequestReloadChildrenOfEntityEvent({ + entityType, + unique, + }); + + // If there is only one language available, we can skip the modal and publish directly: + if (options.length === 1) { + const localizationController = new UmbLocalizationController(this._host); + const confirm = await umbConfirmModal(this, { + headline: localizationController.term('content_readyToPublish'), + content: localizationController.term('prompt_confirmListViewPublish'), + color: 'positive', + confirmLabel: localizationController.term('actions_publish'), + }).catch(() => false); + + if (confirm !== false) { + const variantId = new UmbVariantId(options[0].language.unique, null); + const publishingRepository = new UmbElementPublishingRepository(this._host); + let elementCnt = 0; + + for (let i = 0; i < this.selection.length; i++) { + const id = this.selection[i]; + const { error } = await publishingRepository.publish(id, [{ variantId }]); + + if (!error) { + elementCnt++; + } + } + + notificationContext?.peek('positive', { + data: { + headline: localize.term('speechBubbles_editContentPublishedHeader'), + message: localize.term('speechBubbles_editMultiContentPublishedText', elementCnt), + }, + }); + + eventContext.dispatchEvent(event); + } + return; + } + + // Figure out the default selections + const selection: Array = []; + const context = await this.getContext(UMB_APP_LANGUAGE_CONTEXT); + if (!context) { + throw new Error('App language context not found'); + } + const appCulture = context.getAppCulture(); + // If the app language is one of the options, select it by default: + if (appCulture && options.some((o) => o.unique === appCulture)) { + selection.push(new UmbVariantId(appCulture, null).toString()); + } + + const result = await umbOpenModal(this, UMB_ELEMENT_PUBLISH_MODAL, { + data: { + options, + }, + value: { selection }, + }).catch(() => undefined); + + if (!result?.selection.length) return; + + const variantIds = result?.selection.map((x) => UmbVariantId.FromString(x)) ?? []; + + const repository = new UmbElementPublishingRepository(this._host); + + if (variantIds.length) { + let elementCnt = 0; + for (const unique of this.selection) { + const { error } = await repository.publish( + unique, + variantIds.map((variantId) => ({ variantId })), + ); + + if (!error) { + elementCnt++; + } + } + + notificationContext?.peek('positive', { + data: { + headline: localize.term('speechBubbles_editContentPublishedHeader'), + message: localize.term( + 'speechBubbles_editMultiVariantPublishedText', + elementCnt, + localize.list(variantIds.map((v) => v.culture ?? '')), + ), + }, + }); + + eventContext.dispatchEvent(event); + } + } +} + +export { UmbElementPublishEntityBulkAction as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/manifests.ts new file mode 100644 index 000000000000..5859e55b5533 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/manifests.ts @@ -0,0 +1,11 @@ +import { manifests as entityActionManifests } from './entity-action/manifests.js'; +import { manifests as entityBulkActionManifests } from './entity-bulk-action/manifests.js'; +import { manifests as modalManifests } from './modal/manifests.js'; +import { manifests as workspaceActionManifests } from './workspace-action/manifests.js'; + +export const manifests: Array = [ + ...entityActionManifests, + ...entityBulkActionManifests, + ...modalManifests, + ...workspaceActionManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/constants.ts new file mode 100644 index 000000000000..2417577a41e6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/constants.ts @@ -0,0 +1 @@ +export * from './element-publish-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/element-publish-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/element-publish-modal.element.ts new file mode 100644 index 000000000000..d9b5537c0eb7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/element-publish-modal.element.ts @@ -0,0 +1,155 @@ +import { UmbElementVariantState, type UmbElementVariantOptionModel } from '../../../types.js'; +import type { UmbElementPublishModalData, UmbElementPublishModalValue } from './element-publish-modal.token.js'; +import { css, customElement, html, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; +import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; +import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; + +import '../../../modals/shared/element-variant-language-picker.element.js'; + +/** + * Helper function to check if a variant is not published and has a mandatory language + * @param option + */ +function isNotPublishedMandatory(option: UmbElementVariantOptionModel) { + return ( + option.language?.isMandatory === true && + option.variant?.state !== UmbElementVariantState.PUBLISHED && + option.variant?.state !== UmbElementVariantState.PUBLISHED_PENDING_CHANGES + ); +} + +@customElement('umb-element-publish-modal') +export class UmbElementPublishModalElement extends UmbModalBaseElement< + UmbElementPublishModalData, + UmbElementPublishModalValue +> { + #selectionManager = new UmbSelectionManager(this); + + @state() + private _options: Array = []; + + @state() + private _hasNotSelectedMandatory?: boolean; + + @state() + private _hasInvalidSelection = true; + + @state() + private _isInvariant = false; + + #pickableFilter = (option: UmbElementVariantOptionModel) => { + if (!option.variant || option.variant.state === UmbElementVariantState.NOT_CREATED) { + return false; + } + return this.data?.pickableFilter ? this.data.pickableFilter(option) : true; + }; + + override firstUpdated() { + // If invariant, don't display the variant selection component. + if (this.data?.options.length === 1 && this.data.options[0].culture === null) { + this._isInvariant = true; + this._hasInvalidSelection = false; + return; + } + + this.#configureSelectionManager(); + } + + async #configureSelectionManager() { + this.#selectionManager.setMultiple(true); + this.#selectionManager.setSelectable(true); + + // Only display variants that are relevant to pick from + this._options = + this.data?.options.filter( + (option) => + (option.variant && option.variant.state === null) || + isNotPublishedMandatory(option) || + option.variant?.state !== UmbElementVariantState.NOT_CREATED, + ) ?? []; + + let selected = this.value?.selection ?? []; + + const validOptions = this._options.filter((o) => this.#pickableFilter(o)); + + // Filter selection based on options: + selected = selected.filter((s) => validOptions.some((o) => o.unique === s)); + + this.#selectionManager.setSelection(selected); + + this.observe( + this.#selectionManager.selection, + (selection: Array) => { + if (!this._options && !selection) return; + + // Getting not published mandatory options + const missingMandatoryOptions = this._options.filter(isNotPublishedMandatory); + this._hasNotSelectedMandatory = missingMandatoryOptions.some((option) => !selection.includes(option.unique)); + }, + 'observeSelection', + ); + } + + #submit() { + this.value = { selection: this._isInvariant ? ['invariant'] : this.#selectionManager.getSelection() }; + this.modalContext?.submit(); + } + + #close() { + this.modalContext?.reject(); + } + + override render() { + const headline = this.data?.headline ?? this.localize.term('content_publishModalTitle'); + + return html` + +

+ + ${when( + !this._isInvariant, + () => + html``, + )} + +
+ + +
+
+ `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + min-width: 460px; + max-width: 90vw; + } + `, + ]; +} + +export default UmbElementPublishModalElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-publish-modal': UmbElementPublishModalElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/element-publish-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/element-publish-modal.token.ts new file mode 100644 index 000000000000..71bf1ed8e8fb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/element-publish-modal.token.ts @@ -0,0 +1,21 @@ +import type { UmbElementVariantPickerData, UmbElementVariantPickerValue } from '../../../modals/types.js'; +import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; + +export const UMB_ELEMENT_PUBLISH_MODAL_ALIAS = 'Umb.Modal.ElementPublish'; + +export interface UmbElementPublishModalData extends UmbElementVariantPickerData { + headline?: string; + confirmLabel?: string; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbElementPublishModalValue extends UmbElementVariantPickerValue {} + +export const UMB_ELEMENT_PUBLISH_MODAL = new UmbModalToken( + UMB_ELEMENT_PUBLISH_MODAL_ALIAS, + { + modal: { + type: 'dialog', + }, + }, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/manifests.ts new file mode 100644 index 000000000000..c486dccc1e9e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_ELEMENT_PUBLISH_MODAL_ALIAS } from './element-publish-modal.token.js'; + +export const manifests: Array = [ + { + type: 'modal', + alias: UMB_ELEMENT_PUBLISH_MODAL_ALIAS, + name: 'Element Publish Modal', + element: () => import('./element-publish-modal.element.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/types.ts new file mode 100644 index 000000000000..df6d4ef929b8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/types.ts @@ -0,0 +1 @@ +export type * from './element-publish-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/types.ts new file mode 100644 index 000000000000..4aeeacf57dd0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/types.ts @@ -0,0 +1 @@ +export type * from './modal/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/workspace-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/workspace-action/manifests.ts new file mode 100644 index 000000000000..1bd84534f6ff --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/workspace-action/manifests.ts @@ -0,0 +1,37 @@ +import { UMB_ELEMENT_WORKSPACE_ALIAS } from '../../../workspace/constants.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_PUBLISH, + UMB_USER_PERMISSION_ELEMENT_UPDATE, +} from '../../../user-permissions/constants.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'workspaceAction', + kind: 'default', + alias: 'Umb.WorkspaceAction.Element.SaveAndPublish', + name: 'Save And Publish Element Workspace Action', + weight: 70, + api: () => import('./save-and-publish.action.js'), + meta: { + label: '#buttons_saveAndPublish', + look: 'primary', + color: 'positive', + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_WORKSPACE_ALIAS, + }, + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_UPDATE, UMB_USER_PERMISSION_ELEMENT_PUBLISH], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/workspace-action/save-and-publish.action.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/workspace-action/save-and-publish.action.ts new file mode 100644 index 000000000000..dd5e4a473d4b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/workspace-action/save-and-publish.action.ts @@ -0,0 +1,30 @@ +import { UMB_ELEMENT_PUBLISHING_WORKSPACE_CONTEXT } from '../../workspace-context/constants.js'; +import { UMB_ELEMENT_WORKSPACE_CONTEXT } from '../../../workspace/element-workspace.context-token.js'; +import { UmbWorkspaceActionBase, type UmbWorkspaceActionArgs } from '@umbraco-cms/backoffice/workspace'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbElementSaveAndPublishWorkspaceAction extends UmbWorkspaceActionBase { + constructor(host: UmbControllerHost, args: UmbWorkspaceActionArgs) { + super(host, args); + } + + async hasAdditionalOptions() { + const workspaceContext = await this.getContext(UMB_ELEMENT_WORKSPACE_CONTEXT); + if (!workspaceContext) { + throw new Error('The workspace context is missing'); + } + const variantOptions = await this.observe(workspaceContext.variantOptions).asPromise(); + const cultureVariantOptions = variantOptions?.filter((option) => option.segment === null); + return cultureVariantOptions?.length > 1; + } + + override async execute() { + const workspaceContext = await this.getContext(UMB_ELEMENT_PUBLISHING_WORKSPACE_CONTEXT); + if (!workspaceContext) { + throw new Error('The workspace context is missing'); + } + return workspaceContext.saveAndPublish(); + } +} + +export { UmbElementSaveAndPublishWorkspaceAction as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/constants.ts new file mode 100644 index 000000000000..56a572a9dcb4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_ELEMENT_PUBLISHING_REPOSITORY_ALIAS = 'Umb.Repository.Element.Publishing'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/element-publishing.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/element-publishing.repository.ts new file mode 100644 index 000000000000..1908c006eb72 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/element-publishing.repository.ts @@ -0,0 +1,38 @@ +import type { UmbElementVariantPublishModel } from '../types.js'; +import { UmbElementPublishingServerDataSource } from './element-publishing.server.data-source.js'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; + +export class UmbElementPublishingRepository extends UmbRepositoryBase { + #publishingDataSource = new UmbElementPublishingServerDataSource(this); + + /** + * Publish one or more variants of an Element + * @param {string} unique + * @param {Array} variants + * @returns {*} + * @memberof UmbElementPublishingRepository + */ + async publish(unique: string, variants: Array) { + if (!unique) throw new Error('id is missing'); + if (!variants.length) throw new Error('variant IDs are missing'); + + return this.#publishingDataSource.publish(unique, variants); + } + + /** + * Unpublish one or more variants of an Element + * @param {string} id + * @param {Array} variantIds + * @returns {*} + * @memberof UmbElementPublishingRepository + */ + async unpublish(id: string, variantIds: Array) { + if (!id) throw new Error('id is missing'); + if (!variantIds) throw new Error('variant IDs are missing'); + + return this.#publishingDataSource.unpublish(id, variantIds); + } +} + +export { UmbElementPublishingRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/element-publishing.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/element-publishing.server.data-source.ts new file mode 100644 index 000000000000..27671e16d2c0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/element-publishing.server.data-source.ts @@ -0,0 +1,78 @@ +import type { UmbElementVariantPublishModel } from '../types.js'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; + +/** + * A server data source for Element publishing + * @class UmbElementPublishingServerDataSource + */ +export class UmbElementPublishingServerDataSource { + #host: UmbControllerHost; + + /** + * Creates an instance of UmbElementPublishingServerDataSource. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbElementPublishingServerDataSource + */ + constructor(host: UmbControllerHost) { + this.#host = host; + } + + /** + * Publish one or more variants of an Element + * @param {string} unique + * @param {Array} variants + * @returns {*} + * @memberof UmbElementPublishingServerDataSource + */ + async publish(unique: string, variants: Array) { + if (!unique) throw new Error('Id is missing'); + + const publishSchedules = variants.map((variant) => ({ + culture: variant.variantId.isCultureInvariant() ? null : variant.variantId.toCultureString(), + schedule: variant.schedule ?? null, + })); + + return tryExecute( + this.#host, + ElementService.putElementByIdPublish({ + path: { id: unique }, + body: { publishSchedules }, + }), + ); + } + + /** + * Unpublish one or more variants of an Element + * @param {string} unique + * @param {Array} variantIds + * @returns {*} + * @memberof UmbElementPublishingServerDataSource + */ + async unpublish(unique: string, variantIds: Array) { + if (!unique) throw new Error('Id is missing'); + + // If variants are culture invariant, we need to pass null to the API + const hasInvariant = variantIds.some((variant) => variant.isCultureInvariant()); + + if (hasInvariant) { + return tryExecute( + this.#host, + ElementService.putElementByIdUnpublish({ + path: { id: unique }, + body: { cultures: null }, + }), + ); + } + + return tryExecute( + this.#host, + ElementService.putElementByIdUnpublish({ + path: { id: unique }, + body: { cultures: variantIds.map((variant) => variant.toCultureString()) }, + }), + ); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/index.ts new file mode 100644 index 000000000000..3ea3a736ce8b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/index.ts @@ -0,0 +1,2 @@ +export { UmbElementPublishingRepository } from './element-publishing.repository.js'; +export { UMB_ELEMENT_PUBLISHING_REPOSITORY_ALIAS } from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/manifests.ts new file mode 100644 index 000000000000..e6ab6eabb317 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/repository/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_ELEMENT_PUBLISHING_REPOSITORY_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_ELEMENT_PUBLISHING_REPOSITORY_ALIAS, + name: 'Element Publishing Repository', + api: () => import('./element-publishing.repository.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/constants.ts new file mode 100644 index 000000000000..26f4f0dd5f39 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/constants.ts @@ -0,0 +1 @@ +export * from './modal/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/manifests.ts new file mode 100644 index 000000000000..107aa5246281 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/manifests.ts @@ -0,0 +1,4 @@ +import { manifests as modalManifests } from './modal/manifests.js'; +import { manifests as workspaceActionManifests } from './workspace-action/manifests.js'; + +export const manifests: Array = [...modalManifests, ...workspaceActionManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/constants.ts new file mode 100644 index 000000000000..82468a4c5cac --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/constants.ts @@ -0,0 +1 @@ +export { UMB_ELEMENT_SCHEDULE_MODAL, UMB_ELEMENT_SCHEDULE_MODAL_ALIAS } from './element-schedule-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/element-schedule-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/element-schedule-modal.element.ts new file mode 100644 index 000000000000..d9d02b1a3c41 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/element-schedule-modal.element.ts @@ -0,0 +1,479 @@ +import { UmbElementVariantState, type UmbElementVariantOptionModel } from '../../../types.js'; +import { isNotPublishedMandatory } from '../../utils.js'; +import { UmbElementVariantLanguagePickerElement } from '../../../modals/index.js'; +import type { + UmbElementScheduleModalData, + UmbElementScheduleModalValue, + UmbElementScheduleSelectionModel, +} from './element-schedule-modal.token.js'; +import { css, customElement, html, ref, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; +import { umbBindToValidation, UmbValidationContext } from '@umbraco-cms/backoffice/validation'; +import type { UmbInputDateElement } from '@umbraco-cms/backoffice/components'; +import type { UUIBooleanInputElement, UUIButtonState } from '@umbraco-cms/backoffice/external/uui'; + +@customElement('umb-element-schedule-modal') +export class UmbElementScheduleModalElement extends UmbModalBaseElement< + UmbElementScheduleModalData, + UmbElementScheduleModalValue +> { + #selectionManager = new UmbSelectionManager(this); + + @state() + private _options: Array = []; + + @state() + private _hasNotSelectedMandatory?: boolean; + + @state() + private readonly _selection: Array = []; + + @state() + private _isAllSelected = false; + + @state() + private _internalValues: Array = []; + + @state() + private _submitButtonState?: UUIButtonState; + + #validation = new UmbValidationContext(this); + + #pickableFilter = (option: UmbElementVariantOptionModel) => { + if (isNotPublishedMandatory(option)) { + return true; + } + if (!option.variant || option.variant.state === UmbElementVariantState.NOT_CREATED) { + // If no data present, then its not pickable. + return false; + } + return this.data?.pickableFilter ? this.data.pickableFilter(option) : true; + }; + + constructor() { + super(); + this.observe( + this.#selectionManager.selection, + (selection) => { + if (!this._options && !selection) return; + + // New selections are mapped to the schedule data + this._selection.length = 0; + for (const unique of selection) { + const existing = this._internalValues.find((s) => s.unique === unique); + if (existing) { + this._selection.push(existing); + } + } + this._isAllSelected = this.#isAllSelected(); + + // Getting not published mandatory options — the options that are mandatory and not currently published. + const missingMandatoryOptions = this._options.filter(isNotPublishedMandatory); + this._hasNotSelectedMandatory = missingMandatoryOptions.some((option) => !selection.includes(option.unique)); + + this.requestUpdate(); + }, + '_selection', + ); + } + + override firstUpdated() { + this._internalValues = this.data?.prevalues ? [...this.data.prevalues] : []; + this.#configureSelectionManager(); + } + + async #configureSelectionManager() { + this.#selectionManager.setMultiple(true); + this.#selectionManager.setSelectable(true); + + this.#selectionManager.setAllowLimitation((unique) => { + const option = this._options.find((o) => o.unique === unique); + return option ? this.#pickableFilter(option) : true; + }); + + // Only display variants that are relevant to pick from, i.e. variants that are draft, not-published-mandatory or published with pending changes. + // If we don't know the state (e.g. from a bulk publishing selection) we need to consider it available for selection. + this._options = + this.data?.options.filter( + (option) => + (option.variant && option.variant.state === null) || + isNotPublishedMandatory(option) || + option.variant?.state !== UmbElementVariantState.NOT_CREATED, + ) ?? []; + + let selected = this.data?.activeVariants ?? []; + + // Only display variants that are relevant to pick from, i.e. variants that are draft, not-published-mandatory or published with pending changes. + // If we don't know the state (e.g. from a bulk publishing selection) we need to consider it available for selection. + const validOptions = this._options.filter((option) => this.#pickableFilter(option)); + + // Filter selection based on options: + selected = selected.filter((unique) => validOptions.some((o) => o.unique === unique)); + + this.#selectionManager.setSelection(selected); + } + + async #submit() { + this._submitButtonState = 'waiting'; + try { + await this.#validation.validate(); + this._submitButtonState = 'success'; + this.value = { + selection: this._selection, + }; + this.modalContext?.submit(); + } catch { + this._submitButtonState = 'failed'; + } finally { + this._submitButtonState = undefined; + } + } + + #close() { + this.modalContext?.reject(); + } + + #isSelected(unique: string) { + return this._selection.some((s) => s.unique === unique); + } + + #onSelectAllChange(event: Event) { + const allUniques = this._options.map((o) => o.unique); + const filter = this.#selectionManager.getAllowLimitation(); + const allowedUniques = allUniques.filter((unique) => filter(unique)); + + if ((event.target as UUIBooleanInputElement).checked) { + this.#selectionManager.setSelection(allowedUniques); + } else { + this.#selectionManager.setSelection([]); + } + } + + #isAllSelected() { + const allUniques = this._options.map((o) => o.unique); + const filter = this.#selectionManager.getAllowLimitation(); + const allowedUniques = allUniques.filter((unique) => filter(unique)); + return this._selection.length !== 0 && this._selection.length === allowedUniques.length; + } + + override render() { + return html` + ${this.#renderOptions()} + +
+ + +
+
`; + } + + #renderOptions() { + return html` + ${when( + this._options.length > 1, + () => html` + + `, + )} + ${repeat( + this._options, + (option) => option.unique, + (option) => this.#renderItem(option), + )} + `; + } + + #renderItem(option: UmbElementVariantOptionModel) { + const pickable = this.#pickableFilter(option); + const fromDate = this.#fromDate(option.unique); + const toDate = this.#toDate(option.unique); + const isChanged = + fromDate !== option.variant?.scheduledPublishDate || toDate !== option.variant?.scheduledUnpublishDate; + + return html` + this.#selectionManager.select(option.unique)} + @deselected=${() => this.#selectionManager.deselect(option.unique)} + ?selected=${this.#isSelected(option.unique)}> + + ${UmbElementVariantLanguagePickerElement.renderLabel(option)} + + ${when(this.#isSelected(option.unique), () => this.#renderPublishDateInput(option, fromDate, toDate))} + ${when( + isChanged, + () => + html`

+ ${this.localize.term('content_scheduledPendingChanges', this.localize.term('buttons_schedulePublish'))} +

`, + )} + `; + } + + #attachValidatorsToPublish(element: UmbInputDateElement | null) { + if (!element) return; + + element.addValidator( + 'badInput', + () => this.localize.term('speechBubbles_scheduleErrReleaseDate1'), + () => { + const value = element.value.toString(); + if (!value) return false; + const date = new Date(value); + return date < new Date(); + }, + ); + } + + #attachValidatorsToUnpublish(element: UmbInputDateElement | null, unique: string) { + if (!element) return; + + element.addValidator( + 'badInput', + () => this.localize.term('speechBubbles_scheduleErrExpireDate1'), + () => { + const value = element.value.toString(); + if (!value) return false; + const date = new Date(value); + return date < new Date(); + }, + ); + + element.addValidator( + 'customError', + () => this.localize.term('speechBubbles_scheduleErrExpireDate2'), + () => { + const value = element.value.toString(); + if (!value) return false; + + // Check if the unpublish date is before the publish date + const variant = this._internalValues.find((s) => s.unique === unique); + if (!variant) return false; + const publishTime = variant.schedule?.publishTime; + if (!publishTime) return false; + + const date = new Date(value); + const publishDate = new Date(publishTime); + return date < publishDate; + }, + ); + } + + #renderPublishDateInput(option: UmbElementVariantOptionModel, fromDate: string | null, toDate: string | null) { + return html` +
+ + Publish at +
+ this.#attachValidatorsToPublish(e as UmbInputDateElement))} + ${umbBindToValidation(this)} + type="datetime-local" + .value=${this.#formatDate(fromDate)} + @change=${(e: Event) => this.#onFromDateChange(e, option.unique)} + label=${this.localize.term('general_publishDate')}> +
+ ${when( + fromDate, + () => html` + this.#removeFromDate(option.unique)}> + + + `, + )} +
+
+
+
+ + + Unpublish at +
+ this.#attachValidatorsToUnpublish(e as UmbInputDateElement, option.unique))} + ${umbBindToValidation(this)} + type="datetime-local" + .value=${this.#formatDate(toDate)} + @change=${(e: Event) => this.#onToDateChange(e, option.unique)} + label=${this.localize.term('general_publishDate')}> +
+ ${when( + toDate, + () => html` + this.#removeToDate(option.unique)}> + + + `, + )} +
+
+
+
+
+ `; + } + + #fromDate(unique: string): string | null { + const variant = this._internalValues.find((s) => s.unique === unique); + return variant?.schedule?.publishTime ?? null; + } + + #toDate(unique: string): string | null { + const variant = this._internalValues.find((s) => s.unique === unique); + return variant?.schedule?.unpublishTime ?? null; + } + + #removeFromDate(unique: string): void { + const variant = this._internalValues.find((s) => s.unique === unique); + if (!variant) return; + variant.schedule = { + ...variant.schedule, + publishTime: null, + }; + this.#validation.validate(); + this.requestUpdate('_internalValues'); + } + + #removeToDate(unique: string): void { + const variant = this._internalValues.find((s) => s.unique === unique); + if (!variant) return; + variant.schedule = { + ...variant.schedule, + unpublishTime: null, + }; + this.#validation.validate(); + this.requestUpdate('_internalValues'); + } + + /** + * Formats the date to be compatible with the input type datetime-local + * @param {string} dateStr The date to format, example: 2021-01-01T12:00:00.000+01:00 + * @returns {string | undefined} The formatted date in local time with no offset, example: 2021-01-01T11:00 + */ + #formatDate(dateStr: string | null): string { + if (!dateStr) return ''; + + const d = new Date(dateStr); + + if (isNaN(d.getTime())) { + console.warn('[Schedule]: Invalid date:', dateStr); + return ''; + } + + // We need to subtract the offset to get the correct time in the input field + // the input field expects local time without offset and the Date object will convert the date to local time + return ( + d.getFullYear() + + '-' + + String(d.getMonth() + 1).padStart(2, '0') + + '-' + + String(d.getDate()).padStart(2, '0') + + 'T' + + String(d.getHours()).padStart(2, '0') + + ':' + + String(d.getMinutes()).padStart(2, '0') + ); + } + + #onFromDateChange(e: Event, unique: string) { + const variant = this._internalValues.find((s) => s.unique === unique); + if (!variant) return; + variant.schedule = { + ...variant.schedule, + publishTime: this.#getDateValue(e), + }; + this.#validation.validate(); + this.requestUpdate('_internalValues'); + } + + #onToDateChange(e: Event, unique: string) { + const variant = this._internalValues.find((s) => s.unique === unique); + if (!variant) return; + variant.schedule = { + ...variant.schedule, + unpublishTime: this.#getDateValue(e), + }; + this.#validation.validate(); + this.requestUpdate('_internalValues'); + } + + #getDateValue(e: Event): string | null { + const value = (e.target as UmbInputDateElement).value.toString(); + return value.length ? value : null; + } + + static override readonly styles = [ + UmbTextStyles, + css` + :host { + display: block; + min-width: 600px; + max-width: 90vw; + } + + .label { + padding: 0.5rem 0; + } + .label-status { + font-size: 0.8rem; + } + + .publish-date { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 1rem; + border-top: 1px solid var(--uui-color-border); + border-bottom: 1px solid var(--uui-color-border); + margin-top: var(--uui-size-space-4); + } + + .publish-date > uui-form-layout-item { + flex: 1; + margin: 0; + padding: 0.5rem 0 1rem; + } + + .publish-date > uui-form-layout-item:first-child { + border-right: 1px dashed var(--uui-color-border); + } + + uui-checkbox { + margin-bottom: var(--uui-size-space-3); + } + + uui-menu-item { + --uui-menu-item-flat-structure: 1; + } + `, + ]; +} + +export default UmbElementScheduleModalElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-schedule-modal': UmbElementScheduleModalElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/element-schedule-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/element-schedule-modal.token.ts new file mode 100644 index 000000000000..2b4dda7496a6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/element-schedule-modal.token.ts @@ -0,0 +1,28 @@ +import type { UmbElementVariantPickerData } from '../../../modals/types.js'; +import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; +import type { ScheduleRequestModel } from '@umbraco-cms/backoffice/external/backend-api'; + +export const UMB_ELEMENT_SCHEDULE_MODAL_ALIAS = 'Umb.Modal.ElementSchedule'; + +export interface UmbElementScheduleSelectionModel { + unique: string; + schedule?: ScheduleRequestModel | null; +} + +export interface UmbElementScheduleModalData extends UmbElementVariantPickerData { + activeVariants: Array; + prevalues: Array; +} + +export interface UmbElementScheduleModalValue { + selection: Array; +} + +export const UMB_ELEMENT_SCHEDULE_MODAL = new UmbModalToken( + UMB_ELEMENT_SCHEDULE_MODAL_ALIAS, + { + modal: { + type: 'dialog', + }, + }, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/manifests.ts new file mode 100644 index 000000000000..e2da03badd9e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_ELEMENT_SCHEDULE_MODAL_ALIAS } from './element-schedule-modal.token.js'; + +export const manifests: Array = [ + { + type: 'modal', + alias: UMB_ELEMENT_SCHEDULE_MODAL_ALIAS, + name: 'Element Schedule Modal', + element: () => import('./element-schedule-modal.element.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/types.ts new file mode 100644 index 000000000000..e3b5804b6ae9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/modal/types.ts @@ -0,0 +1 @@ +export type * from './element-schedule-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/workspace-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/workspace-action/manifests.ts new file mode 100644 index 000000000000..f6298bb442ae --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/workspace-action/manifests.ts @@ -0,0 +1,37 @@ +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_PUBLISH, + UMB_USER_PERMISSION_ELEMENT_UPDATE, +} from '../../../user-permissions/constants.js'; +import { UMB_WORKSPACE_ENTITY_IS_NEW_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'workspaceActionMenuItem', + kind: 'default', + alias: 'Umb.Element.WorkspaceActionMenuItem.SchedulePublishing', + name: 'Schedule publishing', + weight: 20, + api: () => import('./save-and-schedule.action.js'), + forWorkspaceActions: 'Umb.WorkspaceAction.Element.SaveAndPublish', + meta: { + label: '#buttons_schedulePublish', + icon: 'icon-globe', + }, + conditions: [ + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_UPDATE, UMB_USER_PERMISSION_ELEMENT_PUBLISH], + }, + { + alias: UMB_WORKSPACE_ENTITY_IS_NEW_CONDITION_ALIAS, + match: false, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/workspace-action/save-and-schedule.action.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/workspace-action/save-and-schedule.action.ts new file mode 100644 index 000000000000..31208358460c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/schedule-publish/workspace-action/save-and-schedule.action.ts @@ -0,0 +1,14 @@ +import { UMB_ELEMENT_PUBLISHING_WORKSPACE_CONTEXT } from '../../workspace-context/constants.js'; +import { UmbWorkspaceActionBase } from '@umbraco-cms/backoffice/workspace'; + +export class UmbElementSaveAndScheduleWorkspaceAction extends UmbWorkspaceActionBase { + override async execute() { + const workspaceContext = await this.getContext(UMB_ELEMENT_PUBLISHING_WORKSPACE_CONTEXT); + if (!workspaceContext) { + throw new Error('Publishing workspace context not found'); + } + return workspaceContext.schedule(); + } +} + +export { UmbElementSaveAndScheduleWorkspaceAction as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/types.ts new file mode 100644 index 000000000000..1d037d24c1dd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/types.ts @@ -0,0 +1,11 @@ +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import type { ScheduleRequestModel } from '@umbraco-cms/backoffice/external/backend-api'; + +export interface UmbElementVariantPublishModel { + variantId: UmbVariantId; + schedule?: ScheduleRequestModel | null; +} + +export type * from './publish/types.js'; +export type * from './unpublish/types.js'; +export type * from './schedule-publish/modal/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/constants.ts new file mode 100644 index 000000000000..26f4f0dd5f39 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/constants.ts @@ -0,0 +1 @@ +export * from './modal/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-action/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-action/index.ts new file mode 100644 index 000000000000..4993625d1f29 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-action/index.ts @@ -0,0 +1 @@ +export * from './unpublish.action.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-action/manifests.ts new file mode 100644 index 000000000000..4fd5527bad94 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-action/manifests.ts @@ -0,0 +1,32 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../../entity.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_UNPUBLISH, +} from '../../../user-permissions/constants.js'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'entityAction', + kind: 'default', + alias: 'Umb.EntityAction.Element.Unpublish', + name: 'Unpublish Element Entity Action', + weight: 500, + api: () => import('./unpublish.action.js'), + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + icon: 'icon-globe', + label: '#actions_unpublish', + additionalOptions: true, + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_UNPUBLISH], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-action/unpublish.action.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-action/unpublish.action.ts new file mode 100644 index 000000000000..f5539f76becf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-action/unpublish.action.ts @@ -0,0 +1,106 @@ +import type { UmbElementVariantOptionModel } from '../../../types.js'; +import { UMB_ELEMENT_UNPUBLISH_MODAL } from '../modal/constants.js'; +import { UmbElementDetailRepository } from '../../../repository/index.js'; +import { UmbElementPublishingRepository } from '../../repository/index.js'; +import { UMB_APP_LANGUAGE_CONTEXT, UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language'; +import { + type UmbEntityActionArgs, + UmbEntityActionBase, + UmbRequestReloadStructureForEntityEvent, +} from '@umbraco-cms/backoffice/entity-action'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; + +export class UmbUnpublishElementEntityAction extends UmbEntityActionBase { + constructor(host: UmbControllerHost, args: UmbEntityActionArgs) { + super(host, args); + } + + override async execute() { + if (!this.args.unique) throw new Error('The element unique identifier is missing'); + + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + const localize = new UmbLocalizationController(this); + + const languageRepository = new UmbLanguageCollectionRepository(this._host); + const { data: languageData } = await languageRepository.requestCollection({}); + + const elementRepository = new UmbElementDetailRepository(this._host); + const { data: elementData } = await elementRepository.requestByUnique(this.args.unique); + + if (!elementData) throw new Error('The element was not found'); + + const appLanguageContext = await this.getContext(UMB_APP_LANGUAGE_CONTEXT); + if (!appLanguageContext) throw new Error('The app language context is missing'); + const appCulture = appLanguageContext.getAppCulture(); + + const cultureVariantOptions = elementData.variants.filter((variant) => variant.segment === null); + + const options: Array = cultureVariantOptions.map( + (variant) => ({ + culture: variant.culture, + segment: variant.segment, + language: languageData?.items.find((language) => language.unique === variant.culture) ?? { + name: appCulture!, + entityType: 'language', + fallbackIsoCode: null, + isDefault: true, + isMandatory: false, + unique: appCulture!, + }, + variant, + unique: new UmbVariantId(variant.culture, variant.segment).toString(), + }), + ); + + // Figure out the default selections + const selection: Array = []; + // If the app language is one of the options, select it by default: + if (appCulture && options.some((o) => o.unique === appCulture)) { + selection.push(new UmbVariantId(appCulture, null).toString()); + } else if (options.length > 0) { + // If not, select the first option by default: + selection.push(options[0].unique); + } + + const result = await umbOpenModal(this, UMB_ELEMENT_UNPUBLISH_MODAL, { + data: { + options, + }, + value: { selection }, + }).catch(() => undefined); + + if (!result?.selection.length) return; + + const variantIds = result?.selection.map((x) => UmbVariantId.FromString(x)) ?? []; + + if (!variantIds.length) return; + + const publishingRepository = new UmbElementPublishingRepository(this._host); + const { error } = await publishingRepository.unpublish(this.args.unique, variantIds); + + if (error) { + throw error; + } + + notificationContext?.peek('positive', { + data: { + message: localize.term('speechBubbles_editContentUnpublishedHeader'), + }, + }); + + const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); + const event = new UmbRequestReloadStructureForEntityEvent({ + unique: this.args.unique, + entityType: this.args.entityType, + }); + + actionEventContext?.dispatchEvent(event); + } +} + +export default UmbUnpublishElementEntityAction; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-bulk-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-bulk-action/manifests.ts new file mode 100644 index 000000000000..ac2304a4b8b8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-bulk-action/manifests.ts @@ -0,0 +1,38 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../../entity.js'; +import { UMB_ELEMENT_COLLECTION_ALIAS } from '../../../collection/constants.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_UNPUBLISH, + UMB_USER_PERMISSION_ELEMENT_UPDATE, +} from '../../../user-permissions/constants.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'entityBulkAction', + kind: 'default', + alias: 'Umb.EntityBulkAction.Element.Unpublish', + name: 'Unpublish Element Entity Bulk Action', + weight: 40, + api: () => import('./unpublish.bulk-action.js'), + meta: { + icon: 'icon-globe', + label: '#actions_unpublish', + }, + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_ELEMENT_COLLECTION_ALIAS, + }, + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_UPDATE, UMB_USER_PERMISSION_ELEMENT_UNPUBLISH], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-bulk-action/unpublish.bulk-action.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-bulk-action/unpublish.bulk-action.ts new file mode 100644 index 000000000000..75810378b545 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/entity-bulk-action/unpublish.bulk-action.ts @@ -0,0 +1,158 @@ +import { UmbUnpublishElementEntityAction } from '../entity-action/index.js'; +import type { UmbElementVariantOptionModel } from '../../../types.js'; +import { UMB_ELEMENT_ENTITY_TYPE } from '../../../entity.js'; +import { UMB_ELEMENT_UNPUBLISH_MODAL } from '../modal/constants.js'; +import { UmbElementPublishingRepository } from '../../repository/index.js'; +import { umbConfirmModal, umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { UmbEntityBulkActionBase } from '@umbraco-cms/backoffice/entity-bulk-action'; +import { UMB_APP_LANGUAGE_CONTEXT, UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; +import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; +import { UmbRequestReloadChildrenOfEntityEvent } from '@umbraco-cms/backoffice/entity-action'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; + +export class UmbElementUnpublishEntityBulkAction extends UmbEntityBulkActionBase { + async execute() { + const entityContext = await this.getContext(UMB_ENTITY_CONTEXT); + if (!entityContext) { + throw new Error('Entity context not found'); + } + const entityType = entityContext.getEntityType(); + const unique = entityContext.getUnique(); + + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + const localize = new UmbLocalizationController(this); + + if (!entityType) throw new Error('Entity type not found'); + if (unique === undefined) throw new Error('Entity unique not found'); + + // If there is only one selection, we can refer to the regular unpublish entity action: + if (this.selection.length === 1) { + const action = new UmbUnpublishElementEntityAction(this._host, { + unique: this.selection[0], + entityType: UMB_ELEMENT_ENTITY_TYPE, + meta: {} as never, + }); + await action.execute(); + return; + } + + const languageRepository = new UmbLanguageCollectionRepository(this._host); + const { data: languageData } = await languageRepository.requestCollection({}); + + const options: UmbElementVariantOptionModel[] = (languageData?.items ?? []).map((language) => ({ + language, + variant: { + name: language.name, + culture: language.unique, + state: null, + createDate: null, + publishDate: null, + updateDate: null, + segment: null, + scheduledPublishDate: null, + scheduledUnpublishDate: null, + flags: [], + }, + unique: new UmbVariantId(language.unique, null).toString(), + culture: language.unique, + segment: null, + })); + + const eventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); + if (!eventContext) { + throw new Error('Event context not found'); + } + const event = new UmbRequestReloadChildrenOfEntityEvent({ + entityType, + unique, + }); + + // If there is only one language available, we can skip the modal and unpublish directly: + if (options.length === 1) { + const localizationController = new UmbLocalizationController(this._host); + const confirm = await umbConfirmModal(this, { + headline: localizationController.term('actions_unpublish'), + content: localizationController.term('prompt_confirmListViewUnpublish'), + color: 'warning', + confirmLabel: localizationController.term('actions_unpublish'), + }).catch(() => false); + + if (confirm !== false) { + const variantId = new UmbVariantId(options[0].language.unique, null); + const publishingRepository = new UmbElementPublishingRepository(this._host); + let elementCnt = 0; + + for (let i = 0; i < this.selection.length; i++) { + const id = this.selection[i]; + const { error } = await publishingRepository.unpublish(id, [variantId]); + + if (!error) { + elementCnt++; + } + } + + notificationContext?.peek('positive', { + data: { + headline: localize.term('speechBubbles_contentUnpublished'), + message: localize.term('speechBubbles_editMultiContentUnpublishedText', elementCnt), + }, + }); + + eventContext.dispatchEvent(event); + } + return; + } + + // Figure out the default selections + const selection: Array = []; + const context = await this.getContext(UMB_APP_LANGUAGE_CONTEXT); + if (!context) throw new Error('App language context not found'); + const appCulture = context.getAppCulture(); + // If the app language is one of the options, select it by default: + if (appCulture && options.some((o) => o.unique === appCulture)) { + selection.push(new UmbVariantId(appCulture, null).toString()); + } + + const result = await umbOpenModal(this, UMB_ELEMENT_UNPUBLISH_MODAL, { + data: { + options, + }, + value: { selection }, + }).catch(() => undefined); + + if (!result?.selection.length) return; + + const variantIds = result?.selection.map((x) => UmbVariantId.FromString(x)) ?? []; + + const repository = new UmbElementPublishingRepository(this._host); + + if (variantIds.length) { + let elementCnt = 0; + for (const unique of this.selection) { + const { error } = await repository.unpublish(unique, variantIds); + + if (!error) { + elementCnt++; + } + } + + notificationContext?.peek('positive', { + data: { + headline: localize.term('speechBubbles_contentUnpublished'), + message: localize.term( + 'speechBubbles_editMultiVariantUnpublishedText', + elementCnt, + localize.list(variantIds.map((v) => v.culture ?? '')), + ), + }, + }); + + eventContext.dispatchEvent(event); + } + } +} + +export { UmbElementUnpublishEntityBulkAction as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/manifests.ts new file mode 100644 index 000000000000..5859e55b5533 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/manifests.ts @@ -0,0 +1,11 @@ +import { manifests as entityActionManifests } from './entity-action/manifests.js'; +import { manifests as entityBulkActionManifests } from './entity-bulk-action/manifests.js'; +import { manifests as modalManifests } from './modal/manifests.js'; +import { manifests as workspaceActionManifests } from './workspace-action/manifests.js'; + +export const manifests: Array = [ + ...entityActionManifests, + ...entityBulkActionManifests, + ...modalManifests, + ...workspaceActionManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/constants.ts new file mode 100644 index 000000000000..3ee317608ee1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/constants.ts @@ -0,0 +1 @@ +export * from './element-unpublish-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/element-unpublish-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/element-unpublish-modal.element.ts new file mode 100644 index 000000000000..bed103ce567a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/element-unpublish-modal.element.ts @@ -0,0 +1,151 @@ +import { UmbElementVariantState, type UmbElementVariantOptionModel } from '../../../types.js'; +import type { UmbElementUnpublishModalData, UmbElementUnpublishModalValue } from './element-unpublish-modal.token.js'; +import { css, customElement, html, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; + +import '../../../modals/shared/element-variant-language-picker.element.js'; + +/** + * @function isPublished + * @param {UmbElementVariantOptionModel} option - the option to check. + * @returns {boolean} boolean + */ +export function isPublished(option: UmbElementVariantOptionModel): boolean { + return ( + option.variant?.state === UmbElementVariantState.PUBLISHED || + option.variant?.state === UmbElementVariantState.PUBLISHED_PENDING_CHANGES + ); +} + +@customElement('umb-element-unpublish-modal') +export class UmbElementUnpublishModalElement extends UmbModalBaseElement< + UmbElementUnpublishModalData, + UmbElementUnpublishModalValue +> { + protected readonly _selectionManager = new UmbSelectionManager(this); + + @state() + private _options: Array = []; + + @state() + private _selection: Array = []; + + @state() + private _hasInvalidSelection = true; + + @state() + private _isInvariant = false; + + #pickableFilter = (option: UmbElementVariantOptionModel) => { + if (!option.variant) { + return false; + } + return this.data?.pickableFilter ? this.data.pickableFilter(option) : true; + }; + + override firstUpdated() { + // If invariant, don't display the variant selection component. + if (this.data?.options.length === 1 && this.data.options[0].culture === null) { + this._isInvariant = true; + this._hasInvalidSelection = false; + return; + } + + this.#configureSelectionManager(); + } + + async #configureSelectionManager() { + this._selectionManager.setMultiple(true); + this._selectionManager.setSelectable(true); + + // Only display variants that are published or published with pending changes. + this._options = + this.data?.options.filter((option) => (option.variant && option.variant.state === null) || isPublished(option)) ?? + []; + + let selected = this.value?.selection ?? []; + + const validOptions = this._options.filter((o) => this.#pickableFilter(o)); + + // Filter selection based on options: + selected = selected.filter((s) => validOptions.some((o) => o.unique === s)); + + this._selectionManager.setSelection(selected); + + this.observe( + this._selectionManager.selection, + (selection) => { + this._selection = selection; + const selectionHasMandatory = this._options.some((o) => o.language.isMandatory && selection.includes(o.unique)); + const selectionDoesNotHaveAllMandatory = this._options.some( + (o) => o.language.isMandatory && !selection.includes(o.unique), + ); + this._hasInvalidSelection = selectionHasMandatory && selectionDoesNotHaveAllMandatory; + }, + 'observeSelection', + ); + } + + #submit() { + const selection = this._isInvariant ? ['invariant'] : this._selection; + this.value = { selection }; + this.modalContext?.submit(); + } + + #close() { + this.modalContext?.reject(); + } + + private _requiredFilter = (variantOption: UmbElementVariantOptionModel): boolean => { + return variantOption.language.isMandatory && !this._selection.includes(variantOption.unique); + }; + + override render() { + return html` +

+ +

+ ${when( + !this._isInvariant, + () => html` + + `, + )} + +
+ + +
+
`; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + min-width: 460px; + max-width: 90vw; + } + `, + ]; +} + +export default UmbElementUnpublishModalElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-unpublish-modal': UmbElementUnpublishModalElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/element-unpublish-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/element-unpublish-modal.token.ts new file mode 100644 index 000000000000..20a514c30262 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/element-unpublish-modal.token.ts @@ -0,0 +1,19 @@ +import type { UmbElementVariantPickerData, UmbElementVariantPickerValue } from '../../../modals/types.js'; +import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; + +export const UMB_ELEMENT_UNPUBLISH_MODAL_ALIAS = 'Umb.Modal.ElementUnpublish'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbElementUnpublishModalData extends UmbElementVariantPickerData {} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbElementUnpublishModalValue extends UmbElementVariantPickerValue {} + +export const UMB_ELEMENT_UNPUBLISH_MODAL = new UmbModalToken< + UmbElementUnpublishModalData, + UmbElementUnpublishModalValue +>(UMB_ELEMENT_UNPUBLISH_MODAL_ALIAS, { + modal: { + type: 'dialog', + }, +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/manifests.ts new file mode 100644 index 000000000000..5e92eada34ba --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_ELEMENT_UNPUBLISH_MODAL_ALIAS } from './element-unpublish-modal.token.js'; + +export const manifests: Array = [ + { + type: 'modal', + alias: UMB_ELEMENT_UNPUBLISH_MODAL_ALIAS, + name: 'Element Unpublish Modal', + element: () => import('./element-unpublish-modal.element.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/types.ts new file mode 100644 index 000000000000..a75a2c5d4eeb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/modal/types.ts @@ -0,0 +1 @@ +export type * from './element-unpublish-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/types.ts new file mode 100644 index 000000000000..4aeeacf57dd0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/types.ts @@ -0,0 +1 @@ +export type * from './modal/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/workspace-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/workspace-action/manifests.ts new file mode 100644 index 000000000000..1600ab9007d2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/workspace-action/manifests.ts @@ -0,0 +1,36 @@ +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_UNPUBLISH, + UMB_USER_PERMISSION_ELEMENT_UPDATE, +} from '../../../user-permissions/constants.js'; +import { UMB_WORKSPACE_ENTITY_IS_NEW_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'workspaceActionMenuItem', + kind: 'default', + alias: 'Umb.Element.WorkspaceActionMenuItem.Unpublish', + name: 'Unpublish Element', + weight: 0, + api: () => import('./unpublish.action.js'), + forWorkspaceActions: 'Umb.WorkspaceAction.Element.SaveAndPublish', + meta: { + label: '#actions_unpublish', + icon: 'icon-globe', + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_UPDATE, UMB_USER_PERMISSION_ELEMENT_UNPUBLISH], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + { + alias: UMB_WORKSPACE_ENTITY_IS_NEW_CONDITION_ALIAS, + match: false, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/workspace-action/unpublish.action.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/workspace-action/unpublish.action.ts new file mode 100644 index 000000000000..1f03b8ba0db6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/unpublish/workspace-action/unpublish.action.ts @@ -0,0 +1,14 @@ +import { UMB_ELEMENT_PUBLISHING_WORKSPACE_CONTEXT } from '../../workspace-context/constants.js'; +import { UmbWorkspaceActionBase } from '@umbraco-cms/backoffice/workspace'; + +export class UmbElementUnpublishWorkspaceAction extends UmbWorkspaceActionBase { + override async execute() { + const workspaceContext = await this.getContext(UMB_ELEMENT_PUBLISHING_WORKSPACE_CONTEXT); + if (!workspaceContext) { + throw new Error('Publishing workspace context not found'); + } + return workspaceContext.unpublish(); + } +} + +export { UmbElementUnpublishWorkspaceAction as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/utils.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/utils.ts new file mode 100644 index 000000000000..bd7636dbb36f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/utils.ts @@ -0,0 +1,14 @@ +import { UmbElementVariantState, type UmbElementVariantOptionModel } from '../types.js'; + +/** + * @function isNotPublishedMandatory + * @param {UmbElementVariantOptionModel} option - the option to check. + * @returns {boolean} boolean + */ +export function isNotPublishedMandatory(option: UmbElementVariantOptionModel): boolean { + return ( + option.language.isMandatory && + option.variant?.state !== UmbElementVariantState.PUBLISHED && + option.variant?.state !== UmbElementVariantState.PUBLISHED_PENDING_CHANGES + ); +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/constants.ts new file mode 100644 index 000000000000..05e26912747e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/constants.ts @@ -0,0 +1,3 @@ +export * from './element-publishing.workspace-context.token.js'; + +export const UMB_ELEMENT_PUBLISHING_SHORTCUT_UNIQUE = 'umb-element-publishing-shortcut'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/element-publishing.workspace-context.token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/element-publishing.workspace-context.token.ts new file mode 100644 index 000000000000..ac49a8c07570 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/element-publishing.workspace-context.token.ts @@ -0,0 +1,9 @@ +import type { UmbElementPublishingWorkspaceContext } from './element-publishing.workspace-context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_ELEMENT_PUBLISHING_WORKSPACE_CONTEXT = new UmbContextToken( + 'UmbWorkspaceContext', + undefined, + (context): context is UmbElementPublishingWorkspaceContext => + (context as UmbElementPublishingWorkspaceContext).saveAndPublish !== undefined, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/element-publishing.workspace-context.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/element-publishing.workspace-context.ts new file mode 100644 index 000000000000..ab2132d1ace0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/element-publishing.workspace-context.ts @@ -0,0 +1,405 @@ +import { UMB_ELEMENT_WORKSPACE_CONTEXT } from '../../workspace/element-workspace.context-token.js'; +import type { UmbElementDetailModel, UmbElementVariantOptionModel } from '../../types.js'; +import { UmbElementVariantState } from '../../types.js'; +import { UmbElementPublishingRepository } from '../repository/index.js'; +import type { UmbElementVariantPublishModel } from '../types.js'; +import { UMB_ELEMENT_PUBLISH_MODAL } from '../publish/constants.js'; +import { UMB_ELEMENT_UNPUBLISH_MODAL } from '../unpublish/constants.js'; +import { UMB_ELEMENT_SCHEDULE_MODAL } from '../schedule-publish/constants.js'; +import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_ELEMENT_PUBLISHING_WORKSPACE_CONTEXT } from './element-publishing.workspace-context.token.js'; +import { UMB_ELEMENT_PUBLISHING_SHORTCUT_UNIQUE } from './constants.js'; +import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; +import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; +import { UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbPublishableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; + +export class UmbElementPublishingWorkspaceContext extends UmbContextBase implements UmbPublishableWorkspaceContext { + #init: Promise; + #elementWorkspaceContext?: typeof UMB_ELEMENT_WORKSPACE_CONTEXT.TYPE; + #eventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; + #publishingRepository = new UmbElementPublishingRepository(this); + #notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE; + readonly #localize = new UmbLocalizationController(this); + + workspaceAlias = UMB_ELEMENT_WORKSPACE_ALIAS; + + constructor(host: UmbControllerHost) { + super(host, UMB_ELEMENT_PUBLISHING_WORKSPACE_CONTEXT); + + this.#init = Promise.all([ + this.consumeContext(UMB_ELEMENT_WORKSPACE_CONTEXT, async (context) => { + if (this.#elementWorkspaceContext) { + // remove shortcut: + this.#elementWorkspaceContext.view.shortcuts.removeOne(UMB_ELEMENT_PUBLISHING_SHORTCUT_UNIQUE); + } + this.#elementWorkspaceContext = context; + this.#elementWorkspaceContext?.view.shortcuts.addOne({ + unique: UMB_ELEMENT_PUBLISHING_SHORTCUT_UNIQUE, + label: this.#localize.term('content_saveAndPublishShortcut'), + key: 'p', + modifier: true, + action: () => this.saveAndPublish(), + }); + }) + .asPromise({ preventTimeout: true }) + .catch(() => { + this.#elementWorkspaceContext = undefined; + }), + + this.consumeContext(UMB_ACTION_EVENT_CONTEXT, async (context) => { + this.#eventContext = context; + }) + .asPromise({ preventTimeout: true }) + .catch(() => { + this.#eventContext = undefined; + }), + ]); + + this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => { + this.#notificationContext = context; + }); + } + + getEntityType() { + return UMB_ELEMENT_ENTITY_TYPE; + } + + public async publish() { + throw new Error('Method not implemented. Use saveAndPublish() instead.'); + } + + /** + * Save and publish the element + * @returns {Promise} + * @memberof UmbElementPublishingWorkspaceContext + */ + public async saveAndPublish(): Promise { + const elementStyle = (this.getHostElement() as HTMLElement).style; + elementStyle.removeProperty('--uui-color-invalid'); + elementStyle.removeProperty('--uui-color-invalid-emphasis'); + elementStyle.removeProperty('--uui-color-invalid-standalone'); + elementStyle.removeProperty('--uui-color-invalid-contrast'); + return this.#handleSaveAndPublish(); + } + + /** + * Unpublish the element + * @returns {Promise} + * @memberof UmbElementPublishingWorkspaceContext + */ + public async unpublish(): Promise { + await this.#init; + if (!this.#elementWorkspaceContext) throw new Error('Element workspace context is missing'); + + const unique = this.#elementWorkspaceContext.getUnique(); + if (!unique) throw new Error('Unique is missing'); + + const entityType = this.#elementWorkspaceContext.getEntityType(); + if (!entityType) throw new Error('Entity type is missing'); + + const { options, selected } = await this.#determineVariantOptions(); + + // Filter to only show published variants + const publishedOptions = options.filter( + (option) => + option.variant?.state === UmbElementVariantState.PUBLISHED || + option.variant?.state === UmbElementVariantState.PUBLISHED_PENDING_CHANGES, + ); + + if (publishedOptions.length === 0) { + this.#notificationContext?.peek('warning', { + data: { message: this.#localize.term('content_itemNotPublished') }, + }); + return; + } + + // If invariant (single culture = null), unpublish directly without modal + if (publishedOptions.length === 1 && publishedOptions[0].culture === null) { + const variantIds = [UmbVariantId.CreateInvariant()]; + await this.#performUnpublish(unique, entityType, variantIds); + return; + } + + const result = await umbOpenModal(this, UMB_ELEMENT_UNPUBLISH_MODAL, { + data: { + options: publishedOptions, + pickableFilter: this.#publishableVariantsFilter, + }, + value: { selection: selected.filter((s) => publishedOptions.some((o) => o.unique === s)) }, + }).catch(() => undefined); + + if (!result?.selection.length) return; + + const variantIds = result.selection.map((x) => UmbVariantId.FromString(x)); + await this.#performUnpublish(unique, entityType, variantIds); + } + + async #performUnpublish(unique: string, entityType: string, variantIds: Array) { + const { error } = await this.#publishingRepository.unpublish(unique, variantIds); + + if (!error) { + this.#notificationContext?.peek('positive', { + data: { message: this.#localize.term('speechBubbles_editElementUnpublishedHeader') }, + }); + + await this.#elementWorkspaceContext?.reload(); + + const event = new UmbRequestReloadStructureForEntityEvent({ unique, entityType }); + this.#eventContext?.dispatchEvent(event); + } + } + + /** + * Schedule the element for publishing + * @returns {Promise} + * @memberof UmbElementPublishingWorkspaceContext + */ + public async schedule(): Promise { + await this.#init; + if (!this.#elementWorkspaceContext) throw new Error('Element workspace context is missing'); + + const unique = this.#elementWorkspaceContext.getUnique(); + if (!unique) throw new Error('Unique is missing'); + + const entityType = this.#elementWorkspaceContext.getEntityType(); + if (!entityType) throw new Error('Entity type is missing'); + + const { options, selected } = await this.#determineVariantOptions(); + + const result = await umbOpenModal(this, UMB_ELEMENT_SCHEDULE_MODAL, { + data: { + options, + activeVariants: selected, + pickableFilter: this.#publishableVariantsFilter, + prevalues: options.map((option) => ({ + unique: option.unique, + schedule: { + publishTime: option.variant?.scheduledPublishDate, + unpublishTime: option.variant?.scheduledUnpublishDate, + }, + })), + }, + }).catch(() => undefined); + + if (!result?.selection.length) return; + + // Map to the correct format for the API (UmbElementVariantPublishModel) + const variants = + result?.selection.map((x) => ({ + variantId: UmbVariantId.FromString(x.unique), + schedule: { + publishTime: this.#convertToDateTimeOffset(x.schedule?.publishTime), + unpublishTime: this.#convertToDateTimeOffset(x.schedule?.unpublishTime), + }, + })) ?? []; + + if (!variants.length) return; + + const variantIds = variants.map((x) => x.variantId); + const saveData = await this.#elementWorkspaceContext.constructSaveData(variantIds); + await this.#elementWorkspaceContext.runMandatoryValidationForSaveData(saveData, variantIds); + await this.#elementWorkspaceContext.askServerToValidate(saveData, variantIds); + + return this.#elementWorkspaceContext.validateAndSubmit( + async () => { + if (!this.#elementWorkspaceContext) { + throw new Error('Element workspace context is missing'); + } + + // Save the element before scheduling + await this.#elementWorkspaceContext.performCreateOrUpdate(variantIds, saveData); + + // Schedule the element + const { error } = await this.#publishingRepository.publish(unique, variants); + if (error) { + return Promise.reject(error); + } + + const notification = { data: { message: this.#localize.term('speechBubbles_editContentScheduledSavedText') } }; + this.#notificationContext?.peek('positive', notification); + + // reload the element so all states are updated after the schedule operation + await this.#elementWorkspaceContext.reload(); + + // request reload of this entity + const structureEvent = new UmbRequestReloadStructureForEntityEvent({ entityType, unique }); + this.#eventContext?.dispatchEvent(structureEvent); + }, + async (reason?: unknown) => { + this.#notificationContext?.peek('danger', { + data: { message: this.#localize.term('speechBubbles_editContentScheduledNotSavedText') }, + }); + + return Promise.reject(reason); + }, + ); + } + + /** + * Convert a date string to a server time string in ISO format, example: 2021-01-01T12:00:00.000+00:00. + * The input must be a valid date string, otherwise it will return null. + * The output matches the DateTimeOffset format in C#. + * @param dateString + */ + #convertToDateTimeOffset(dateString: string | null | undefined) { + if (!dateString || dateString.length === 0) { + return null; + } + + const date = new Date(dateString); + + if (isNaN(date.getTime())) { + console.warn(`[Schedule]: Invalid date: ${dateString}`); + return null; + } + + // Convert the date to UTC time in ISO format before sending it to the server + return date.toISOString(); + } + + async #handleSaveAndPublish() { + await this.#init; + if (!this.#elementWorkspaceContext) throw new Error('Element workspace context is missing'); + + const unique = this.#elementWorkspaceContext.getUnique(); + if (!unique) throw new Error('Unique is missing'); + + let variantIds: Array = []; + + const { options, selected } = await this.#determineVariantOptions(); + + // If there is only one variant, we don't need to open the modal. + if (options.length === 0) { + throw new Error('No variants are available'); + } else if (options.length === 1) { + // If only one option we will skip ahead and save the element with the only variant available: + variantIds.push(UmbVariantId.Create(options[0])); + } else { + // If there are multiple variants, we will open the modal to let the user pick which variants to publish. + const result = await umbOpenModal(this, UMB_ELEMENT_PUBLISH_MODAL, { + data: { + headline: this.#localize.term('content_saveAndPublishModalTitle'), + options, + pickableFilter: this.#publishableVariantsFilter, + }, + value: { selection: selected }, + }).catch(() => undefined); + + if (!result?.selection.length || !unique) return; + + variantIds = result?.selection.map((x) => UmbVariantId.FromString(x)) ?? []; + } + + const saveData = await this.#elementWorkspaceContext.constructSaveData(variantIds); + await this.#elementWorkspaceContext.runMandatoryValidationForSaveData(saveData, variantIds); + await this.#elementWorkspaceContext.askServerToValidate(saveData, variantIds); + + return this.#elementWorkspaceContext.validateAndSubmit( + async () => { + return this.#performSaveAndPublish(variantIds, saveData); + }, + async (reason?: unknown) => { + // If data of the selection is not valid Then just save: + await this.#elementWorkspaceContext!.performCreateOrUpdate(variantIds, saveData); + // Notifying that the save was successful, but we did not publish + this.#notificationContext?.peek('danger', { + data: { message: this.#localize.term('speechBubbles_editContentPublishedFailedByValidation') }, + }); + return await Promise.reject(reason); + }, + ); + } + + async #performSaveAndPublish(variantIds: Array, saveData: UmbElementDetailModel): Promise { + await this.#init; + if (!this.#elementWorkspaceContext) throw new Error('Element workspace context is missing'); + + const unique = this.#elementWorkspaceContext.getUnique(); + if (!unique) throw new Error('Unique is missing'); + + const entityType = this.#elementWorkspaceContext.getEntityType(); + if (!entityType) throw new Error('Entity type is missing'); + + await this.#elementWorkspaceContext.performCreateOrUpdate(variantIds, saveData); + + const { error } = await this.#publishingRepository.publish( + unique, + variantIds.map((variantId) => ({ variantId })), + ); + + if (!error) { + this.#notificationContext?.peek('positive', { + data: { message: this.#localize.term('speechBubbles_editElementPublishedHeader') }, + }); + + // reload the element so all states are updated after the publish operation + await this.#elementWorkspaceContext.reload(); + + const event = new UmbRequestReloadStructureForEntityEvent({ unique, entityType }); + this.#eventContext?.dispatchEvent(event); + } + } + + #publishableVariantsFilter = (option: UmbElementVariantOptionModel) => { + const variantId = UmbVariantId.Create(option); + // If the read only guard is permitted it means the variant is read only + const isReadOnly = this.#elementWorkspaceContext!.readOnlyGuard.getIsPermittedForVariant(variantId); + // If the variant is read only, we can't publish it + return !isReadOnly; + }; + + async #determineVariantOptions(): Promise<{ + options: UmbElementVariantOptionModel[]; + selected: string[]; + }> { + await this.#init; + if (!this.#elementWorkspaceContext) throw new Error('Element workspace context is missing'); + + const allOptions = await firstValueFrom(this.#elementWorkspaceContext.variantOptions); + const options = allOptions.filter((option) => option.segment === null); + + let selected = this.#getPublishVariantsSelection(); + + // Selected can contain entries that are not part of the options, therefor filter based on options. + selected = selected.filter((x) => options.some((o) => o.unique === x)); + + // Filter out read-only variants + selected = selected.filter( + (x) => this.#elementWorkspaceContext!.readOnlyGuard.getIsPermittedForVariant(new UmbVariantId(x)) === false, + ); + + return { + options, + selected, + }; + } + + #getPublishVariantsSelection() { + if (!this.#elementWorkspaceContext) throw new Error('Element workspace context is missing'); + const activeVariants = this.#elementWorkspaceContext.splitView.getActiveVariants(); + const activeVariantIds = activeVariants.map((x) => UmbVariantId.Create(x)); + const changedVariantIds = this.#elementWorkspaceContext.getChangedVariants(); + const activeAndChangedVariantIds = [...activeVariantIds, ...changedVariantIds]; + + // if a segment has been changed, we select the "parent" culture variant + const changedParentCultureVariantIds = activeAndChangedVariantIds + .filter((x) => x.segment !== null) + .map((x) => x.toSegmentInvariant()); + + const selected = [...activeAndChangedVariantIds, ...changedParentCultureVariantIds].map((variantId) => + variantId.toString(), + ); + + return [...new Set(selected)]; + } +} + +export { UmbElementPublishingWorkspaceContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/manifests.ts new file mode 100644 index 000000000000..a5b2a4251305 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/manifests.ts @@ -0,0 +1,17 @@ +import { UMB_ELEMENT_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'workspaceContext', + name: 'Element Publishing Workspace Context', + alias: 'Umb.WorkspaceContext.Element.Publishing', + api: () => import('./element-publishing.workspace-context.js'), + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_WORKSPACE_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/types.ts new file mode 100644 index 000000000000..652046a9917c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/publishing/workspace-context/types.ts @@ -0,0 +1 @@ +export type * from './element-publishing.workspace-context.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/action/manifests.ts new file mode 100644 index 000000000000..5375907948a1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/action/manifests.ts @@ -0,0 +1,31 @@ +import { UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS } from '../../repository/constants.js'; +import { UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS } from '../constants.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_DELETE, +} from '../../../user-permissions/constants.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; +import type { ManifestCollectionActionEmptyRecycleBinKind } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'collectionAction', + kind: 'emptyRecycleBin', + name: 'Element Collection Empty Recycle Bin Action', + alias: 'Umb.CollectionAction.Element.EmptyRecycleBin', + meta: { + label: '#actions_emptyrecyclebin', + recycleBinRepositoryAlias: UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS, + }, + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS, + }, + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_DELETE], + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/constants.ts new file mode 100644 index 000000000000..123ff78d257f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/constants.ts @@ -0,0 +1,3 @@ +export * from './repository/constants.js'; + +export const UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS = 'Umb.Collection.Element.RecycleBin'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/index.ts new file mode 100644 index 000000000000..6c11f6abbb12 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/index.ts @@ -0,0 +1,2 @@ +export * from './constants.js'; +export * from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/manifests.ts new file mode 100644 index 000000000000..78ff3e39fd3b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/manifests.ts @@ -0,0 +1,20 @@ +import { manifests as actionManifests } from './action/manifests.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as viewManifests } from './views/manifests.js'; +import { UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS } from './constants.js'; +import { UMB_ELEMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS } from './repository/index.js'; + +export const manifests: Array = [ + { + type: 'collection', + kind: 'default', + alias: UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS, + name: 'Element Recycle Bin Tree Item Children Collection', + meta: { + repositoryAlias: UMB_ELEMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS, + }, + }, + ...actionManifests, + ...repositoryManifests, + ...viewManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/constants.ts new file mode 100644 index 000000000000..f2ee31058a2b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/constants.ts @@ -0,0 +1,2 @@ +export const UMB_ELEMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS = + 'Umb.Repository.ElementRecycleBin.TreeItemChildrenCollection'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/element-recycle-bin-tree-item-children-collection.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/element-recycle-bin-tree-item-children-collection.repository.ts new file mode 100644 index 000000000000..a1f27d6d4e4a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/element-recycle-bin-tree-item-children-collection.repository.ts @@ -0,0 +1,34 @@ +import { UmbElementRecycleBinTreeRepository } from '../../tree/element-recycle-bin-tree.repository.js'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity'; +import type { UmbCollectionFilterModel, UmbCollectionRepository } from '@umbraco-cms/backoffice/collection'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export class UmbElementRecycleBinTreeItemChildrenCollectionRepository + extends UmbRepositoryBase + implements UmbCollectionRepository +{ + #treeRepository = new UmbElementRecycleBinTreeRepository(this); + + async requestCollection(filter: UmbCollectionFilterModel) { + // TODO: get parent from args + const entityContext = await this.getContext(UMB_ENTITY_CONTEXT); + if (!entityContext) throw new Error('Entity context not found'); + + const entityType = entityContext.getEntityType(); + const unique = entityContext.getUnique(); + + if (!entityType) throw new Error('Entity type not found'); + if (unique === undefined) throw new Error('Unique not found'); + + const parent: UmbEntityModel = { entityType, unique }; + + if (parent.unique === null) { + return this.#treeRepository.requestTreeRootItems({ skip: filter.skip, take: filter.take }); + } else { + return this.#treeRepository.requestTreeItemsOf({ parent, skip: filter.skip, take: filter.take }); + } + } +} + +export { UmbElementRecycleBinTreeItemChildrenCollectionRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/index.ts new file mode 100644 index 000000000000..4f07201dcf0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/index.ts @@ -0,0 +1 @@ +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/manifests.ts new file mode 100644 index 000000000000..59be4e3d3ac0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/repository/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_ELEMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_ELEMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS, + name: 'Element Recycle Bin Tree Item Children Collection Repository', + api: () => import('./element-recycle-bin-tree-item-children-collection.repository.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/types.ts new file mode 100644 index 000000000000..1371cd5665b0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/types.ts @@ -0,0 +1,6 @@ +import type { UmbCollectionFilterModel } from '@umbraco-cms/backoffice/collection'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbElementRecycleBinTreeItemChildrenCollectionFilterModel extends UmbCollectionFilterModel { + parent: UmbEntityModel; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/views/element-recycle-bin-tree-item-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/views/element-recycle-bin-tree-item-table-collection-view.element.ts new file mode 100644 index 000000000000..eac72aab1931 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/views/element-recycle-bin-tree-item-table-collection-view.element.ts @@ -0,0 +1,101 @@ +import type { UmbElementRecycleBinTreeItemModel } from '../../tree/types.js'; +import type { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection'; +import { UMB_COLLECTION_CONTEXT } from '@umbraco-cms/backoffice/collection'; +import type { UmbTableColumn, UmbTableConfig, UmbTableItem } from '@umbraco-cms/backoffice/components'; +import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin'; + +import './trashed-element-name-table-column.element.js'; + +@customElement('umb-element-recycle-bin-tree-item-table-collection-view') +export class UmbElementRecycleBinTreeItemTableCollectionViewElement extends UmbLitElement { + @state() + private _tableConfig: UmbTableConfig = { + allowSelection: false, + }; + + @state() + private _tableColumns: Array = [ + { + name: this.localize.term('general_name'), + alias: 'name', + elementName: 'umb-trashed-element-name-table-column', + }, + { + name: '', + alias: 'entityActions', + align: 'right', + }, + ]; + + @state() + private _tableItems: Array = []; + + #collectionContext?: UmbDefaultCollectionContext; + #isTrashedContext = new UmbIsTrashedEntityContext(this); + + constructor() { + super(); + this.#isTrashedContext.setIsTrashed(true); + + this.consumeContext(UMB_COLLECTION_CONTEXT, (instance) => { + this.#collectionContext = instance; + this.#observeCollectionItems(); + }); + } + + #observeCollectionItems() { + if (!this.#collectionContext) return; + this.observe(this.#collectionContext.items, (items) => this.#createTableItems(items), 'umbCollectionItemsObserver'); + } + + #createTableItems(items: Array) { + this._tableItems = items.map((item) => { + return { + id: item.unique, + icon: item.documentType.icon, + data: [ + { + columnAlias: 'name', + value: item, + }, + { + columnAlias: 'entityActions', + value: html``, + }, + ], + }; + }); + } + + override render() { + return html` + + `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: flex; + flex-direction: column; + } + `, + ]; +} + +export { UmbElementRecycleBinTreeItemTableCollectionViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-recycle-bin-tree-item-table-collection-view': UmbElementRecycleBinTreeItemTableCollectionViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/views/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/views/manifests.ts new file mode 100644 index 000000000000..e5c0e496eb10 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/views/manifests.ts @@ -0,0 +1,23 @@ +import { UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS } from '../constants.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; + +export const manifests: Array = [ + { + type: 'collectionView', + alias: 'Umb.CollectionView.ElementRecycleBin.TreeItem.Table', + name: 'Element Recycle Bin Tree Item Table Collection View', + element: () => import('./element-recycle-bin-tree-item-table-collection-view.element.js'), + weight: 300, + meta: { + label: 'Table', + icon: 'icon-table', + pathName: 'table', + }, + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/views/trashed-element-name-table-column.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/views/trashed-element-name-table-column.element.ts new file mode 100644 index 000000000000..e2c35790d381 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/collection/views/trashed-element-name-table-column.element.ts @@ -0,0 +1,68 @@ +import { UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN } from '../../../paths.js'; +import { UMB_EDIT_ELEMENT_FOLDER_WORKSPACE_PATH_PATTERN } from '../../../folder/workspace/constants.js'; +import type { UmbElementRecycleBinTreeItemModel } from '../../tree/types.js'; +//import { UmbElementItemDataResolver } from '../../../item/data-resolver/element-item-data-resolver.js'; +import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbTableColumn, UmbTableColumnLayoutElement, UmbTableItem } from '@umbraco-cms/backoffice/components'; + +@customElement('umb-trashed-element-name-table-column') +export class UmbTrashedElementNameTableColumnElement extends UmbLitElement implements UmbTableColumnLayoutElement { + //#resolver = new UmbElementItemDataResolver(this); + + @state() + private _name = ''; + + @state() + private _editPath = ''; + + column!: UmbTableColumn; + item!: UmbTableItem; + + @property({ attribute: false }) + public set value(value: UmbElementRecycleBinTreeItemModel) { + this.#value = value; + + if (value) { + //this.#resolver.setData(value); + this._name = value.name; + + const pathPattern = value.isFolder + ? UMB_EDIT_ELEMENT_FOLDER_WORKSPACE_PATH_PATTERN + : UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN; + + this._editPath = pathPattern.generateAbsolute({ + unique: value.unique, + }); + } + } + public get value(): UmbElementRecycleBinTreeItemModel { + return this.#value; + } + #value!: UmbElementRecycleBinTreeItemModel; + + constructor() { + super(); + //this.#resolver.observe(this.#resolver.name, (name) => (this._name = name || '')); + } + + override render() { + if (!this.value) return nothing; + if (!this._name) return nothing; + return html``; + } + + static override styles = [ + css` + uui-button { + text-align: left; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-trashed-element-name-table-column': UmbTrashedElementNameTableColumnElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/conditions/allow-element-recycle-bin.condition.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/conditions/allow-element-recycle-bin.condition.ts new file mode 100644 index 000000000000..e431277a76f0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/conditions/allow-element-recycle-bin.condition.ts @@ -0,0 +1,28 @@ +import { UmbConditionBase } from '@umbraco-cms/backoffice/extension-registry'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { + UmbConditionConfigBase, + UmbConditionControllerArguments, + UmbExtensionCondition, +} from '@umbraco-cms/backoffice/extension-api'; +import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; + +export const UMB_CURRENT_USER_ALLOW_ELEMENT_RECYCLE_BIN_CONDITION_ALIAS = + 'Umb.Condition.CurrentUser.AllowElementRecycleBin'; + +export class UmbAllowElementRecycleBinCurrentUserCondition + extends UmbConditionBase + implements UmbExtensionCondition +{ + constructor(host: UmbControllerHost, args: UmbConditionControllerArguments) { + super(host, args); + + this.consumeContext(UMB_CURRENT_USER_CONTEXT, (context) => { + this.observe(context?.hasElementRootAccess, (hasAccess) => { + this.permitted = hasAccess === true; + }); + }); + } +} + +export { UmbAllowElementRecycleBinCurrentUserCondition as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/conditions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/conditions/manifests.ts new file mode 100644 index 000000000000..e15686730282 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/conditions/manifests.ts @@ -0,0 +1,13 @@ +import { + UmbAllowElementRecycleBinCurrentUserCondition, + UMB_CURRENT_USER_ALLOW_ELEMENT_RECYCLE_BIN_CONDITION_ALIAS, +} from './allow-element-recycle-bin.condition.js'; + +export const manifests: Array = [ + { + type: 'condition', + name: 'Allow Element Recycle Bin Current User Condition', + alias: UMB_CURRENT_USER_ALLOW_ELEMENT_RECYCLE_BIN_CONDITION_ALIAS, + api: UmbAllowElementRecycleBinCurrentUserCondition, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/constants.ts new file mode 100644 index 000000000000..15ea9ece9b98 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/constants.ts @@ -0,0 +1,4 @@ +export * from './menu/constants.js'; +export * from './repository/constants.js'; +export * from './root/constants.js'; +export * from './tree/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/entity-actions/manifests.ts new file mode 100644 index 000000000000..5b5d166fffbd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/entity-actions/manifests.ts @@ -0,0 +1,136 @@ +import { UMB_ELEMENT_DETAIL_REPOSITORY_ALIAS } from '../../repository/detail/constants.js'; +import { UMB_ELEMENT_ENTITY_TYPE, UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS } from '../../folder/repository/constants.js'; +import { UMB_ELEMENT_ITEM_REPOSITORY_ALIAS } from '../../item/constants.js'; +import { + UMB_ELEMENT_FOLDER_RECYCLE_BIN_REPOSITORY_ALIAS, + UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS, + UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE, +} from '../constants.js'; +import { UMB_ELEMENT_REFERENCE_REPOSITORY_ALIAS } from '../../reference/constants.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_DELETE, +} from '../../user-permissions/constants.js'; +import { UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS } from '@umbraco-cms/backoffice/entity-action'; +import { + UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS, +} from '@umbraco-cms/backoffice/recycle-bin'; + +const elementActions: Array = [ + { + type: 'entityAction', + kind: 'trashWithRelation', + alias: 'Umb.EntityAction.Element.RecycleBin.Trash', + name: 'Trash Element Entity Action', + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + itemRepositoryAlias: UMB_ELEMENT_ITEM_REPOSITORY_ALIAS, + recycleBinRepositoryAlias: UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS, + referenceRepositoryAlias: UMB_ELEMENT_REFERENCE_REPOSITORY_ALIAS, + }, + + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_DELETE], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], + }, + { + type: 'entityAction', + kind: 'deleteWithRelation', + alias: 'Umb.EntityAction.Element.Delete', + name: 'Delete Element Entity Action', + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + icon: 'icon-trash-empty', + itemRepositoryAlias: UMB_ELEMENT_ITEM_REPOSITORY_ALIAS, + detailRepositoryAlias: UMB_ELEMENT_DETAIL_REPOSITORY_ALIAS, + referenceRepositoryAlias: UMB_ELEMENT_REFERENCE_REPOSITORY_ALIAS, + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_DELETE], + }, + { alias: UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS }, + ], + }, + // { + // type: 'entityAction', + // kind: 'restoreFromRecycleBin', + // alias: 'Umb.EntityAction.Element.RecycleBin.Restore', + // name: 'Restore Element From Recycle Bin Entity Action', + // forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + // meta: { + // itemRepositoryAlias: UMB_ELEMENT_ITEM_REPOSITORY_ALIAS, + // itemDataResolver: UmbElementItemDataResolver, + // recycleBinRepositoryAlias: UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS, + // pickerModal: UMB_ELEMENT_PICKER_MODAL, + // }, + // conditions: [ + // { + // alias: UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS, + // }, + // ], + // }, +]; + +const folderActions: Array = [ + { + type: 'entityAction', + kind: 'trashFolder', + alias: 'Umb.EntityAction.Element.Folder.Trash', + name: 'Trash Element Folder Entity Action', + forEntityTypes: [UMB_ELEMENT_FOLDER_ENTITY_TYPE], + meta: { + folderRepositoryAlias: UMB_ELEMENT_FOLDER_REPOSITORY_ALIAS, + recycleBinRepositoryAlias: UMB_ELEMENT_FOLDER_RECYCLE_BIN_REPOSITORY_ALIAS, + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_DELETE], + }, + { alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS }, + ], + }, +]; + +const emptyRecycleBin: UmbExtensionManifest = { + type: 'entityAction', + kind: 'emptyRecycleBin', + alias: 'Umb.EntityAction.Element.RecycleBin.Empty', + name: 'Empty Element Recycle Bin Entity Action', + forEntityTypes: [UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE], + meta: { + recycleBinRepositoryAlias: UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS, + }, + conditions: [ + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_DELETE], + }, + { alias: UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS }, + ], +}; + +const reloadTreeItemChildren: UmbExtensionManifest = { + type: 'entityAction', + kind: 'reloadTreeItemChildren', + alias: 'Umb.EntityAction.ElementRecycleBin.Tree.ReloadChildrenOf', + name: 'Reload Element Recycle Bin Tree Item Children Entity Action', + forEntityTypes: [UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE], +}; + +export const manifests: Array = [ + ...elementActions, + ...folderActions, + emptyRecycleBin, + reloadTreeItemChildren, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/entity-bulk-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/entity-bulk-actions/manifests.ts new file mode 100644 index 000000000000..823ed98e5d22 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/entity-bulk-actions/manifests.ts @@ -0,0 +1,42 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_ITEM_REPOSITORY_ALIAS } from '../../item/constants.js'; +import { UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS } from '../repository/constants.js'; +import { UMB_ELEMENT_REFERENCE_REPOSITORY_ALIAS } from '../../reference/constants.js'; +import { UMB_ELEMENT_COLLECTION_ALIAS } from '../../collection/constants.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_DELETE, +} from '../../user-permissions/constants.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; +import { UMB_ENTITY_BULK_ACTION_TRASH_WITH_RELATION_KIND } from '@umbraco-cms/backoffice/relations'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; +import type { ManifestEntityBulkActionTrashWithRelationKind } from '@umbraco-cms/backoffice/relations'; + +const trash: ManifestEntityBulkActionTrashWithRelationKind = { + type: 'entityBulkAction', + kind: UMB_ENTITY_BULK_ACTION_TRASH_WITH_RELATION_KIND, + alias: 'Umb.EntityBulkAction.Element.Trash', + name: 'Trash Element Entity Bulk Action', + weight: 10, + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + itemRepositoryAlias: UMB_ELEMENT_ITEM_REPOSITORY_ALIAS, + recycleBinRepositoryAlias: UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS, + referenceRepositoryAlias: UMB_ELEMENT_REFERENCE_REPOSITORY_ALIAS, + }, + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_ELEMENT_COLLECTION_ALIAS, + }, + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_DELETE], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], +}; + +export const manifests: Array = [trash]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/folder/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/folder/manifests.ts new file mode 100644 index 000000000000..ee76e96781a2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/folder/manifests.ts @@ -0,0 +1,29 @@ +import { UMB_ELEMENT_FOLDER_WORKSPACE_ALIAS } from '../../folder/workspace/constants.js'; +import { UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS } from '../collection/constants.js'; +import { UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; +import type { ManifestWorkspaceViewCollectionKind } from '@umbraco-cms/backoffice/collection'; + +const workspaceView: ManifestWorkspaceViewCollectionKind = { + type: 'workspaceView', + kind: 'collection', + alias: 'Umb.WorkspaceView.ElementRecycleBin.Folder', + name: 'Element Recycle Bin Root Collection Workspace View', + meta: { + label: 'Collection', + pathname: 'collection', + icon: 'icon-layers', + collectionAlias: UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_FOLDER_WORKSPACE_ALIAS, + }, + { + alias: UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS, + }, + ], +}; + +export const manifests: Array = [workspaceView]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/index.ts new file mode 100644 index 000000000000..3b3cea0f9444 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/index.ts @@ -0,0 +1 @@ +export * from './tree/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/manifests.ts new file mode 100644 index 000000000000..d0efc2bffeea --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/manifests.ts @@ -0,0 +1,21 @@ +import { manifests as collectionManifests } from './collection/manifests.js'; +import { manifests as conditionsManifests } from './conditions/manifests.js'; +import { manifests as entityActionManifests } from './entity-actions/manifests.js'; +import { manifests as entityBulkActionManifests } from './entity-bulk-actions/manifests.js'; +import { manifests as folderManifests } from './folder/manifests.js'; +import { manifests as menuManifests } from './menu/manifests.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as rootManifests } from './root/manifests.js'; +import { manifests as treeManifests } from './tree/manifests.js'; + +export const manifests: Array = [ + ...collectionManifests, + ...conditionsManifests, + ...entityActionManifests, + ...entityBulkActionManifests, + ...folderManifests, + ...menuManifests, + ...repositoryManifests, + ...rootManifests, + ...treeManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/constants.ts new file mode 100644 index 000000000000..f7a05b7e0e01 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/constants.ts @@ -0,0 +1 @@ +export const UMB_ELEMENT_RECYCLE_BIN_MENU_ITEM_ALIAS = 'Umb.MenuItem.Element.RecycleBin'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/element-recycle-bin-menu-structure.context.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/element-recycle-bin-menu-structure.context.ts new file mode 100644 index 000000000000..d0e438e25e23 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/element-recycle-bin-menu-structure.context.ts @@ -0,0 +1,11 @@ +import { UMB_ELEMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS } from '../tree/constants.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbMenuVariantTreeStructureWorkspaceContextBase } from '@umbraco-cms/backoffice/menu'; + +export class UmbElementRecycleBinMenuStructureContext extends UmbMenuVariantTreeStructureWorkspaceContextBase { + constructor(host: UmbControllerHost) { + super(host, { treeRepositoryAlias: UMB_ELEMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS }); + } +} + +export { UmbElementRecycleBinMenuStructureContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/manifests.ts new file mode 100644 index 000000000000..2e3e9f2aadf6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/menu/manifests.ts @@ -0,0 +1,46 @@ +import { UMB_CURRENT_USER_ALLOW_ELEMENT_RECYCLE_BIN_CONDITION_ALIAS } from '../conditions/allow-element-recycle-bin.condition.js'; +import { UMB_ELEMENT_MENU_ALIAS } from '../../menu/constants.js'; +import { UMB_ELEMENT_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_ELEMENT_RECYCLE_BIN_TREE_ALIAS } from '../tree/constants.js'; +import { UMB_ELEMENT_RECYCLE_BIN_MENU_ITEM_ALIAS } from './constants.js'; +import { UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; +import type { ManifestMenuItemTreeKind } from '@umbraco-cms/backoffice/tree'; +import type { ManifestWorkspaceContextMenuStructureKind } from '@umbraco-cms/backoffice/menu'; + +const menuItem: ManifestMenuItemTreeKind = { + type: 'menuItem', + kind: 'tree', + alias: UMB_ELEMENT_RECYCLE_BIN_MENU_ITEM_ALIAS, + name: 'Element Recycle Bin Menu Item', + weight: 100, + meta: { + label: '#general_recycleBin', + icon: 'icon-trash', + menus: [UMB_ELEMENT_MENU_ALIAS], + treeAlias: UMB_ELEMENT_RECYCLE_BIN_TREE_ALIAS, + }, + conditions: [{ alias: UMB_CURRENT_USER_ALLOW_ELEMENT_RECYCLE_BIN_CONDITION_ALIAS }], +}; + +const workspaceContext: ManifestWorkspaceContextMenuStructureKind = { + type: 'workspaceContext', + kind: 'menuStructure', + name: 'Element Recycle Bin Menu Structure Workspace Context', + alias: 'Umb.Context.ElementRecycleBin.Menu.Structure', + api: () => import('./element-recycle-bin-menu-structure.context.js'), + meta: { + menuItemAlias: UMB_ELEMENT_RECYCLE_BIN_MENU_ITEM_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_WORKSPACE_ALIAS, + }, + { + alias: UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS, + }, + ], +}; + +export const manifests: Array = [menuItem, workspaceContext]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/constants.ts new file mode 100644 index 000000000000..0fd00a1a62e6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/constants.ts @@ -0,0 +1,2 @@ +export const UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS = 'Umb.Repository.Element.RecycleBin'; +export const UMB_ELEMENT_FOLDER_RECYCLE_BIN_REPOSITORY_ALIAS = 'Umb.Repository.Element.Folder.RecycleBin'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-folder-recycle-bin.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-folder-recycle-bin.repository.ts new file mode 100644 index 000000000000..f98a880f5faf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-folder-recycle-bin.repository.ts @@ -0,0 +1,11 @@ +import { UmbElementFolderRecycleBinServerDataSource } from './element-folder-recycle-bin.server.data-source.js'; +import { UmbRecycleBinRepositoryBase } from '@umbraco-cms/backoffice/recycle-bin'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbElementFolderRecycleBinRepository extends UmbRecycleBinRepositoryBase { + constructor(host: UmbControllerHost) { + super(host, UmbElementFolderRecycleBinServerDataSource); + } +} + +export { UmbElementFolderRecycleBinRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-folder-recycle-bin.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-folder-recycle-bin.server.data-source.ts new file mode 100644 index 000000000000..81441fe20ed4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-folder-recycle-bin.server.data-source.ts @@ -0,0 +1,57 @@ +import type { + UmbRecycleBinDataSource, + UmbRecycleBinRestoreRequestArgs, + UmbRecycleBinTrashRequestArgs, + UmbRecycleBinOriginalParentRequestArgs, +} from '@umbraco-cms/backoffice/recycle-bin'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; + +export class UmbElementFolderRecycleBinServerDataSource implements UmbRecycleBinDataSource { + #host: UmbControllerHost; + + constructor(host: UmbControllerHost) { + this.#host = host; + } + + trash(args: UmbRecycleBinTrashRequestArgs) { + return tryExecute(this.#host, ElementService.putElementFolderByIdMoveToRecycleBin({ path: { id: args.unique } })); + } + + restore(args: UmbRecycleBinRestoreRequestArgs) { + return tryExecute( + this.#host, + Promise.reject(`Recycle bin folder restore has not been implemented yet. (${args.unique})`), + // TODO: Uncomment this when backend endpoint is available. [LK:2026-01-06] + // ElementService.putRecycleBinElementFolderByIdRestore({ + // path: { id: args.unique }, + // body: { + // target: args.destination.unique ? { id: args.destination.unique } : null, + // }, + // }), + ); + } + + empty() { + return tryExecute(this.#host, ElementService.deleteRecycleBinElement()); + } + + async getOriginalParent(args: UmbRecycleBinOriginalParentRequestArgs) { + const { data, error } = await tryExecute( + this.#host, + Promise.reject(`Recycle bin folder restore has not been implemented yet. (${args.unique})`), + // TODO: Uncomment this when backend endpoint is available. [LK:2026-01-06] + //ElementService.getRecycleBinElementFolderByIdOriginalParent({ path: { id: args.unique } }), + ); + + // only check for undefined because data can be null if the parent is the root + if (data !== undefined) { + // TODO: Uncomment this when backend endpoint is available. [LK:2026-01-06] + // const mappedData = data ? { unique: data.id } : null; + // return { data: mappedData }; + } + + return { error }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-recycle-bin.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-recycle-bin.repository.ts new file mode 100644 index 000000000000..8f0c2a7d3eb3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-recycle-bin.repository.ts @@ -0,0 +1,11 @@ +import { UmbElementRecycleBinServerDataSource } from './element-recycle-bin.server.data-source.js'; +import { UmbRecycleBinRepositoryBase } from '@umbraco-cms/backoffice/recycle-bin'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbElementRecycleBinRepository extends UmbRecycleBinRepositoryBase { + constructor(host: UmbControllerHost) { + super(host, UmbElementRecycleBinServerDataSource); + } +} + +export { UmbElementRecycleBinRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-recycle-bin.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-recycle-bin.server.data-source.ts new file mode 100644 index 000000000000..a5aef1214950 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/element-recycle-bin.server.data-source.ts @@ -0,0 +1,57 @@ +import type { + UmbRecycleBinDataSource, + UmbRecycleBinRestoreRequestArgs, + UmbRecycleBinTrashRequestArgs, + UmbRecycleBinOriginalParentRequestArgs, +} from '@umbraco-cms/backoffice/recycle-bin'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; + +export class UmbElementRecycleBinServerDataSource implements UmbRecycleBinDataSource { + #host: UmbControllerHost; + + constructor(host: UmbControllerHost) { + this.#host = host; + } + + trash(args: UmbRecycleBinTrashRequestArgs) { + return tryExecute(this.#host, ElementService.putElementByIdMoveToRecycleBin({ path: { id: args.unique } })); + } + + restore(args: UmbRecycleBinRestoreRequestArgs) { + return tryExecute( + this.#host, + Promise.reject(`Recycle bin restore has not been implemented yet. (${args.unique})`), + // TODO: Uncomment this when backend endpoint is available. [LK:2026-01-06] + // ElementService.putRecycleBinElementByIdRestore({ + // path: { id: args.unique }, + // body: { + // target: args.destination.unique ? { id: args.destination.unique } : null, + // }, + // }), + ); + } + + empty() { + return tryExecute(this.#host, ElementService.deleteRecycleBinElement()); + } + + async getOriginalParent(args: UmbRecycleBinOriginalParentRequestArgs) { + const { data, error } = await tryExecute( + this.#host, + Promise.reject(`Recycle bin restore has not been implemented yet. (${args.unique})`), + // TODO: Uncomment this when backend endpoint is available. [LK:2026-01-06] + //ElementService.getRecycleBinElementByIdOriginalParent({ path: { id: args.unique } }), + ); + + // only check for undefined because data can be null if the parent is the root + if (data !== undefined) { + // TODO: Uncomment this when backend endpoint is available. [LK:2026-01-06] + // const mappedData = data ? { unique: data.id } : null; + // return { data: mappedData }; + } + + return { error }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/manifests.ts new file mode 100644 index 000000000000..27b3cc7d2395 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/repository/manifests.ts @@ -0,0 +1,19 @@ +import { + UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS, + UMB_ELEMENT_FOLDER_RECYCLE_BIN_REPOSITORY_ALIAS, +} from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_ELEMENT_RECYCLE_BIN_REPOSITORY_ALIAS, + name: 'Element Recycle Bin Repository', + api: () => import('./element-recycle-bin.repository.js'), + }, + { + type: 'repository', + alias: UMB_ELEMENT_FOLDER_RECYCLE_BIN_REPOSITORY_ALIAS, + name: 'Element Folder Recycle Bin Repository', + api: () => import('./element-folder-recycle-bin.repository.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/constants.ts new file mode 100644 index 000000000000..3cd5db0dd684 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/constants.ts @@ -0,0 +1,2 @@ +export * from './entity.js'; +export * from './workspace/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/entity.ts new file mode 100644 index 000000000000..ca36c32f5e93 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/entity.ts @@ -0,0 +1 @@ +export const UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE = 'element-recycle-bin-root'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/manifests.ts new file mode 100644 index 000000000000..f3edde04ca19 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as workspaceManifests } from './workspace/manifests.js'; + +export const manifests: Array = [...workspaceManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/workspace/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/workspace/constants.ts new file mode 100644 index 000000000000..0e29b4b125a1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/workspace/constants.ts @@ -0,0 +1 @@ +export const UMB_ELEMENT_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS = 'Umb.Workspace.Element.RecycleBin.Root'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/workspace/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/workspace/manifests.ts new file mode 100644 index 000000000000..07702345d343 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/root/workspace/manifests.ts @@ -0,0 +1,38 @@ +import { UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS } from '../../collection/constants.js'; +import { UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../entity.js'; +import { UMB_ELEMENT_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS } from './constants.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; +import type { ManifestWorkspaceDefaultKind } from '@umbraco-cms/backoffice/workspace'; +import type { ManifestWorkspaceViewCollectionKind } from '@umbraco-cms/backoffice/collection'; + +const workspace: ManifestWorkspaceDefaultKind = { + type: 'workspace', + kind: 'default', + alias: UMB_ELEMENT_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS, + name: 'Element Recycle Bin Root Workspace', + meta: { + entityType: UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE, + headline: '#general_recycleBin', + }, +}; + +const workspaceView: ManifestWorkspaceViewCollectionKind = { + type: 'workspaceView', + kind: 'collection', + alias: 'Umb.WorkspaceView.ElementRecycleBinRoot.Root', + name: 'Element Recycle Bin Root Collection Workspace View', + meta: { + label: 'Collection', + pathname: 'collection', + icon: 'icon-layers', + collectionAlias: UMB_ELEMENT_RECYCLE_BIN_COLLECTION_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS, + }, + ], +}; + +export const manifests: Array = [workspace, workspaceView]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/constants.ts new file mode 100644 index 000000000000..da1aa3bc232a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/constants.ts @@ -0,0 +1,2 @@ +export const UMB_ELEMENT_RECYCLE_BIN_TREE_ALIAS = 'Umb.Tree.Element.RecycleBin'; +export const UMB_ELEMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS = 'Umb.Repository.Element.RecycleBin.Tree'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/element-recycle-bin-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/element-recycle-bin-tree.repository.ts new file mode 100644 index 000000000000..87766b8c39e9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/element-recycle-bin-tree.repository.ts @@ -0,0 +1,34 @@ +import { UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../constants.js'; +import type { UmbElementRecycleBinTreeItemModel, UmbElementRecycleBinTreeRootModel } from '../types.js'; +import { UmbElementRecycleBinTreeServerDataSource } from './element-recycle-bin-tree.server.data-source.js'; +import { UmbTreeRepositoryBase } from '@umbraco-cms/backoffice/tree'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; + +export class UmbElementRecycleBinTreeRepository + extends UmbTreeRepositoryBase + implements UmbApi +{ + constructor(host: UmbControllerHost) { + super(host, UmbElementRecycleBinTreeServerDataSource); + } + + async requestTreeRoot() { + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); + const hasChildren = treeRootData ? treeRootData.total > 0 : false; + + const data = { + unique: null, + entityType: UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE, + name: '#treeHeaders_contentRecycleBin', + icon: 'icon-trash', + hasChildren, + isContainer: false, + isFolder: true, + }; + + return { data }; + } +} + +export { UmbElementRecycleBinTreeRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/element-recycle-bin-tree.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/element-recycle-bin-tree.server.data-source.ts new file mode 100644 index 000000000000..9aa81785081d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/element-recycle-bin-tree.server.data-source.ts @@ -0,0 +1,98 @@ +import { UMB_ELEMENT_ENTITY_TYPE, UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../constants.js'; +import type { UmbElementRecycleBinTreeItemModel } from '../types.js'; +import type { UmbElementTreeItemVariantModel } from '../../tree/types.js'; +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbTreeServerDataSourceBase } from '@umbraco-cms/backoffice/tree'; +import type { ElementRecycleBinItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { + UmbTreeAncestorsOfRequestArgs, + UmbTreeChildrenOfRequestArgs, + UmbTreeRootItemsRequestArgs, +} from '@umbraco-cms/backoffice/tree'; + +/** + * A data source for the Element Recycle Bin tree that fetches data from the server + * @class UmbElementRecycleBinTreeServerDataSource + * @implements {UmbTreeDataSource} + */ +export class UmbElementRecycleBinTreeServerDataSource extends UmbTreeServerDataSourceBase< + ElementRecycleBinItemResponseModel, + UmbElementRecycleBinTreeItemModel +> { + /** + * Creates an instance of UmbElementRecycleBinTreeServerDataSource. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbElementRecycleBinTreeServerDataSource + */ + constructor(host: UmbControllerHost) { + super(host, { + getRootItems, + getChildrenOf, + getAncestorsOf, + mapper, + }); + } +} + +const getRootItems = (args: UmbTreeRootItemsRequestArgs) => + // eslint-disable-next-line local-rules/no-direct-api-import + ElementService.getRecycleBinElementRoot({ query: { skip: args.skip, take: args.take } }); + +const getChildrenOf = (args: UmbTreeChildrenOfRequestArgs) => { + if (args.parent.unique === null) { + return getRootItems(args); + } else { + // eslint-disable-next-line local-rules/no-direct-api-import + return ElementService.getRecycleBinElementChildren({ + query: { parentId: args.parent.unique, skip: args.skip, take: args.take }, + }); + } +}; + +const getAncestorsOf = (args: UmbTreeAncestorsOfRequestArgs) => + // eslint-disable-next-line local-rules/no-direct-api-import + ElementService.getTreeElementAncestors({ + query: { descendantId: args.treeItem.unique }, + }); + +// TODO: Review the commented out properties. [LK:2026-01-06] +const mapper = (item: ElementRecycleBinItemResponseModel): UmbElementRecycleBinTreeItemModel => { + return { + unique: item.id, + parent: { + unique: item.parent ? item.parent.id : null, + entityType: item.parent + ? item.isFolder + ? UMB_ELEMENT_FOLDER_ENTITY_TYPE + : UMB_ELEMENT_ENTITY_TYPE + : UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE, + }, + entityType: item.isFolder ? UMB_ELEMENT_FOLDER_ENTITY_TYPE : UMB_ELEMENT_ENTITY_TYPE, + icon: item.isFolder ? 'icon-folder' : (item.documentType?.icon ?? 'icon-document'), + isTrashed: true, + hasChildren: item.hasChildren, + //isProtected: false, + documentType: { + unique: item.documentType?.id ?? '', + icon: item.isFolder ? 'icon-folder' : (item.documentType?.icon ?? 'icon-document'), + collection: null, + }, + variants: item.variants.map((variant): UmbElementTreeItemVariantModel => { + return { + name: variant.name, + culture: variant.culture || null, + segment: null, // TODO: add segment to the backend API? + state: variant.state, + //flags: variant.flags ?? [], + }; + }), + // TODO: this is not correct. We need to get it from the variants. This is a temp solution. [LK] + name: item.isFolder ? item.name : item.variants[0]?.name, + isFolder: item.isFolder, + createDate: item.createDate, + // TODO: Recycle bin items should have flags, but the API does not return any at the moment. [NL] + flags: (item as any).flags ?? [], + }; +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/index.ts new file mode 100644 index 000000000000..2869c08d8007 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/index.ts @@ -0,0 +1 @@ +export { UmbElementRecycleBinTreeRepository } from './element-recycle-bin-tree.repository.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/manifests.ts new file mode 100644 index 000000000000..0e5f8741e861 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/manifests.ts @@ -0,0 +1,36 @@ +import { UMB_ELEMENT_ENTITY_TYPE, UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../root/constants.js'; +import { UMB_ELEMENT_RECYCLE_BIN_TREE_ALIAS, UMB_ELEMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS } from './constants.js'; +import type { ManifestRepository } from '@umbraco-cms/backoffice/extension-registry'; +import type { ManifestTree } from '@umbraco-cms/backoffice/tree'; +import type { ManifestTreeItemRecycleBinKind } from '@umbraco-cms/backoffice/recycle-bin'; + +const repository: ManifestRepository = { + type: 'repository', + alias: UMB_ELEMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS, + name: 'Element Recycle Bin Tree Repository', + api: () => import('./element-recycle-bin-tree.repository.js'), +}; + +const tree: ManifestTree = { + type: 'tree', + kind: 'default', + alias: UMB_ELEMENT_RECYCLE_BIN_TREE_ALIAS, + name: 'Element Recycle Bin Tree', + meta: { + repositoryAlias: UMB_ELEMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS, + }, +}; + +const treeItem: ManifestTreeItemRecycleBinKind = { + type: 'treeItem', + kind: 'recycleBin', + alias: 'Umb.TreeItem.Element.RecycleBin', + name: 'Element Recycle Bin Tree Item', + forEntityTypes: [UMB_ELEMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE], + meta: { + supportedEntityTypes: [UMB_ELEMENT_ENTITY_TYPE, UMB_ELEMENT_FOLDER_ENTITY_TYPE], + }, +}; + +export const manifests: Array = [repository, tree, treeItem]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/types.ts new file mode 100644 index 000000000000..5d7b9e9a9bf3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/tree/types.ts @@ -0,0 +1,8 @@ +import type { UmbElementTreeItemModel } from '../../tree/index.js'; +import type { UmbTreeRootModel } from '@umbraco-cms/backoffice/tree'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbElementRecycleBinTreeItemModel extends Omit {} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbElementRecycleBinTreeRootModel extends UmbTreeRootModel {} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/types.ts new file mode 100644 index 000000000000..e985632c60ad --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/recycle-bin/types.ts @@ -0,0 +1 @@ +export type * from './tree/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/constants.ts new file mode 100644 index 000000000000..41a409dec1f0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/constants.ts @@ -0,0 +1 @@ +export * from './repository/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/index.ts new file mode 100644 index 000000000000..6c11f6abbb12 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/index.ts @@ -0,0 +1,2 @@ +export * from './constants.js'; +export * from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/info-app/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/info-app/manifests.ts new file mode 100644 index 000000000000..1b9aa74136f0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/info-app/manifests.ts @@ -0,0 +1,29 @@ +import { UMB_ELEMENT_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_ELEMENT_REFERENCE_REPOSITORY_ALIAS } from '../constants.js'; +import { + UMB_WORKSPACE_CONDITION_ALIAS, + UMB_WORKSPACE_ENTITY_IS_NEW_CONDITION_ALIAS, +} from '@umbraco-cms/backoffice/workspace'; +import type { ManifestWorkspaceInfoApp } from '@umbraco-cms/backoffice/workspace'; + +const workspaceInfoApp: ManifestWorkspaceInfoApp = { + type: 'workspaceInfoApp', + kind: 'entityReferences', + name: 'Element References Workspace Info App', + alias: 'Umb.WorkspaceInfoApp.Element.References', + meta: { + referenceRepositoryAlias: UMB_ELEMENT_REFERENCE_REPOSITORY_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_WORKSPACE_ALIAS, + }, + { + alias: UMB_WORKSPACE_ENTITY_IS_NEW_CONDITION_ALIAS, + match: false, + }, + ], +}; + +export const manifests: Array = [workspaceInfoApp]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/manifests.ts new file mode 100644 index 000000000000..d804039738aa --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/manifests.ts @@ -0,0 +1,4 @@ +import { manifests as infoAppManifests } from './info-app/manifests.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; + +export const manifests: Array = [...infoAppManifests, ...repositoryManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/constants.ts new file mode 100644 index 000000000000..68be380da622 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/constants.ts @@ -0,0 +1 @@ +export const UMB_ELEMENT_REFERENCE_REPOSITORY_ALIAS = 'Umb.Repository.Element.Reference'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/element-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/element-reference-response.management-api.mapping.ts new file mode 100644 index 000000000000..18b44cefe2f8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/element-reference-response.management-api.mapping.ts @@ -0,0 +1,31 @@ +// TODO: Uncomment this when `ElementReferenceResponseModel` API type is available. [LK:2026-01-06] +// import type { UmbElementReferenceModel } from '../types.js'; +// import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; +// import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +// import type { ElementReferenceResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +// import type { UmbDataSourceDataMapping } from '@umbraco-cms/backoffice/repository'; + +// export class UmbElementReferenceResponseManagementApiDataMapping +// extends UmbControllerBase +// implements UmbDataSourceDataMapping +// { +// async map(data: ElementReferenceResponseModel): Promise { +// return { +// documentType: { +// alias: data.ElementType.alias!, +// icon: data.ElementType.icon!, +// name: data.ElementType.name!, +// unique: data.ElementType.id, +// }, +// entityType: UMB_ELEMENT_ENTITY_TYPE, +// variants: data.variants.map((variant) => ({ +// ...variant, +// // TODO: Review if we should make `culture` to allow `undefined`. [LK] +// culture: variant.culture ?? null, +// })), +// unique: data.id, +// }; +// } +// } + +// export { UmbElementReferenceResponseManagementApiDataMapping as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/element-reference.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/element-reference.repository.ts new file mode 100644 index 000000000000..713f86fbb19f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/element-reference.repository.ts @@ -0,0 +1,30 @@ +import { UmbElementReferenceServerDataSource } from './element-reference.server.data.js'; +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 class UmbElementReferenceRepository extends UmbControllerBase implements UmbEntityReferenceRepository { + #referenceSource: UmbElementReferenceServerDataSource; + + constructor(host: UmbControllerHost) { + super(host); + this.#referenceSource = new UmbElementReferenceServerDataSource(this); + } + + async requestReferencedBy(unique: string, skip = 0, take = 20) { + if (!unique) throw new Error(`unique is required`); + return this.#referenceSource.getReferencedBy(unique, skip, take); + } + + 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); + } + + async requestDescendantsWithReferences(unique: string, skip = 0, take = 20) { + if (!unique) throw new Error(`unique is required`); + return this.#referenceSource.getReferencedDescendants(unique, skip, take); + } +} + +export default UmbElementReferenceRepository; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/element-reference.server.data.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/element-reference.server.data.ts new file mode 100644 index 000000000000..c8b7a401cb6a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/element-reference.server.data.ts @@ -0,0 +1,137 @@ +//import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; +//import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import { /*tryExecute,*/ UmbError } from '@umbraco-cms/backoffice/resources'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +//import { UmbManagementApiDataMapper } from '@umbraco-cms/backoffice/repository'; +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'; + +/** + * @class UmbElementReferenceServerDataSource + * @implements {UmbEntityReferenceDataSource} + */ +export class UmbElementReferenceServerDataSource extends UmbControllerBase implements UmbEntityReferenceDataSource { + //#dataMapper = new UmbManagementApiDataMapper(this); + + /** + * Fetches the item for the given unique from the server + * @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 UmbElementReferenceServerDataSource + */ + async getReferencedBy( + unique: string, + skip = 0, + take = 20, + ): Promise>> { + return { error: new UmbError(`'getReferencedBy' has not been implemented yet. (${unique}, ${skip}, ${take})`) }; + + // TODO: Uncomment this when backend endpoint is available. [LK:2026-01-06] + // const { data, error } = await tryExecute( + // this, + // ElementService.getElementByIdReferencedBy({ path: { id: unique }, query: { skip, take } }), + // ); + + // TODO: Uncomment this when backend endpoint is available. [LK:2026-01-06] + // 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 UmbElementReferenceServerDataSource + */ + async getAreReferenced( + uniques: Array, + skip: number = 0, + take: number = 20, + ): Promise>> { + return { + error: new UmbError(`'getElementAreReferenced' has not been implemented yet. (${uniques}, ${skip}, ${take})`), + }; + + // TODO: Uncomment this when backend endpoint is available. [LK:2026-01-06] + // const { data, error } = await tryExecute( + // this, + // ElementService.getElementAreReferenced({ query: { id: uniques, skip, take } }), + // ); + + // if (data) { + // const items: Array = data.items.map((item) => { + // return { + // unique: item.id, + // entityType: UMB_ELEMENT_ENTITY_TYPE, + // }; + // }); + + // return { data: { items, total: data.total } }; + // } + + //return { data, error }; + } + + /** + * Returns any descendants of the given unique that is referenced by other items + * @param {string} unique - The unique identifier of the item to fetch descendants for + * @param {number} skip - The number of items to skip + * @param {number} take - The number of items to take + * @returns {Promise>>} - Any descendants of the given unique that is referenced by other items + * @memberof UmbElementReferenceServerDataSource + */ + async getReferencedDescendants( + unique: string, + skip: number = 0, + take: number = 20, + ): Promise>> { + return { + error: new UmbError( + `'getElementByIdReferencedDescendants' has not been implemented yet. (${unique}, ${skip}, ${take})`, + ), + }; + + // TODO: Uncomment this when backend endpoint is available. [LK:2026-01-06] + // const { data, error } = await tryExecute( + // this, + // ElementService.getElementByIdReferencedDescendants({ path: { id: unique }, query: { skip, take } }), + // ); + + // if (data) { + // const items: Array = data.items.map((item) => { + // return { + // unique: item.id, + // entityType: UMB_ELEMENT_ENTITY_TYPE, + // }; + // }); + + // return { data: { items, total: data.total } }; + // } + + //return { data, error }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/index.ts new file mode 100644 index 000000000000..6a216c2792b1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/index.ts @@ -0,0 +1 @@ +export * from './element-reference.repository.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/manifests.ts new file mode 100644 index 000000000000..6ec7f249bc5e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/repository/manifests.ts @@ -0,0 +1,19 @@ +import { UMB_ELEMENT_REFERENCE_REPOSITORY_ALIAS } from './constants.js'; +//import { UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS } from '@umbraco-cms/backoffice/repository'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_ELEMENT_REFERENCE_REPOSITORY_ALIAS, + name: 'Element Reference Repository', + api: () => import('./element-reference.repository.js'), + }, + // { + // type: 'dataSourceDataMapping', + // alias: 'Umb.DataSourceDataMapping.ManagementApi.ElementReferenceResponse', + // name: 'Element Reference Response Management Api Data Mapping', + // api: () => import('./element-reference-response.management-api.mapping.js'), + // forDataSource: UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS, + // forDataModel: 'ElementReferenceResponseModel', + // }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/reference/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/types.ts new file mode 100644 index 000000000000..46f7df9e14c3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/reference/types.ts @@ -0,0 +1,12 @@ +import type { UmbElementItemVariantModel } from '../item/types.js'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbElementReferenceModel extends UmbEntityModel { + documentType: { + alias: string; + icon: string; + name: string; + unique: string; + }; + variants: Array; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/constants.ts new file mode 100644 index 000000000000..9bae2774f9bd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/constants.ts @@ -0,0 +1,2 @@ +export * from './detail/constants.js'; +//export * from './item/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/constants.ts new file mode 100644 index 000000000000..f9544a9335c3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/constants.ts @@ -0,0 +1,4 @@ +export const UMB_ELEMENT_DETAIL_REPOSITORY_ALIAS = 'Umb.Repository.Element.Detail'; +export const UMB_ELEMENT_DETAIL_STORE_ALIAS = 'Umb.Store.Element.Detail'; + +export { UMB_ELEMENT_DETAIL_STORE_CONTEXT } from './element-detail.store.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.repository.ts new file mode 100644 index 000000000000..add37c132af9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.repository.ts @@ -0,0 +1,16 @@ +import type { UmbElementDetailModel } from '../../types.js'; +import { UmbElementServerDataSource } from './element-detail.server.data-source.js'; +import { UMB_ELEMENT_DETAIL_STORE_CONTEXT } from './element-detail.store.context-token.js'; +import { UmbDetailRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbElementDetailRepository extends UmbDetailRepositoryBase< + UmbElementDetailModel, + UmbElementServerDataSource +> { + constructor(host: UmbControllerHost) { + super(host, UmbElementServerDataSource, UMB_ELEMENT_DETAIL_STORE_CONTEXT); + } +} + +export { UmbElementDetailRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.server.data-source.ts new file mode 100644 index 000000000000..1a843b9d4175 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.server.data-source.ts @@ -0,0 +1,207 @@ +import type { UmbElementDetailModel } from '../../types.js'; +import { UMB_ELEMENT_ENTITY_TYPE, UMB_ELEMENT_PROPERTY_VALUE_ENTITY_TYPE } from '../../entity.js'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbId } from '@umbraco-cms/backoffice/id'; +import type { UmbDataSourceResponse, UmbDetailDataSource } from '@umbraco-cms/backoffice/repository'; +import type { + CreateElementRequestModel, + ElementResponseModel, + UpdateElementRequestModel, +} from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +/** + * A data source for the Document that fetches data from the server + * @class UmbElementServerDataSource + * @implements {UmbDetailDataSource} + */ +export class UmbElementServerDataSource implements UmbDetailDataSource { + #host: UmbControllerHost; + + /** + * Creates an instance of UmbElementServerDataSource. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbElementServerDataSource + */ + constructor(host: UmbControllerHost) { + this.#host = host; + } + + /** + * Creates a new Document scaffold + * @param preset + * @returns { UmbElementDetailModel } + * @memberof UmbElementServerDataSource + */ + async createScaffold(preset: Partial = {}) { + const data: UmbElementDetailModel = { + entityType: UMB_ELEMENT_ENTITY_TYPE, + unique: UmbId.new(), + documentType: { + unique: '', + collection: null, + }, + isTrashed: false, + values: [], + variants: [], + flags: [], + ...preset, + }; + + return { data }; + } + + /** + * Creates a new variant scaffold. + * @returns A new variant scaffold. + */ + /* + // TODO: remove if not used + createVariantScaffold(): UmbElementVariantModel { + return { + state: null, + culture: null, + segment: null, + name: '', + publishDate: null, + createDate: null, + updateDate: null, + }; + } + */ + + /** + * Fetches a Document with the given id from the server + * @param {string} unique + * @returns {*} + * @memberof UmbElementServerDataSource + */ + async read(unique: string): Promise> { + if (!unique) throw new Error('Unique is missing'); + + const { data, error } = await tryExecute(this.#host, ElementService.getElementById({ path: { id: unique } })); + + if (error || !data) { + return { error }; + } + + const document = this.#createElementDetailModel(data); + + return { data: document }; + } + + /** + * Inserts a new Document on the server + * @param {UmbElementDetailModel} model + * @param parentUnique + * @returns {*} + * @memberof UmbElementServerDataSource + */ + async create(model: UmbElementDetailModel, parentUnique: string | null = null) { + if (!model) throw new Error('Document is missing'); + if (!model.unique) throw new Error('Document unique is missing'); + + // TODO: make data mapper to prevent errors + const body: CreateElementRequestModel = { + id: model.unique, + parent: parentUnique ? { id: parentUnique } : null, + documentType: { id: model.documentType.unique }, + values: model.values, + variants: model.variants, + }; + + const { data, error } = await tryExecute( + this.#host, + ElementService.postElement({ + body, + }), + ); + + if (data && typeof data === 'string') { + return this.read(data); + } + + return { error }; + } + + /** + * Updates a Document on the server + * @param {UmbElementDetailModel} Document + * @param model + * @returns {*} + * @memberof UmbElementServerDataSource + */ + async update(model: UmbElementDetailModel) { + if (!model.unique) throw new Error('Unique is missing'); + + // TODO: make data mapper to prevent errors + const body: UpdateElementRequestModel = { + values: model.values, + variants: model.variants, + }; + + const { error } = await tryExecute( + this.#host, + ElementService.putElementById({ + path: { id: model.unique }, + body, + }), + ); + + if (!error) { + return this.read(model.unique); + } + + return { error }; + } + + /** + * Deletes a Document on the server + * @param {string} unique + * @returns {*} + * @memberof UmbElementServerDataSource + */ + async delete(unique: string) { + if (!unique) throw new Error('Unique is missing'); + + return tryExecute(this.#host, ElementService.deleteElementById({ path: { id: unique } })); + } + + #createElementDetailModel(data: ElementResponseModel): UmbElementDetailModel { + return { + entityType: UMB_ELEMENT_ENTITY_TYPE, + unique: data.id, + values: data.values.map((value) => { + return { + editorAlias: value.editorAlias, + entityType: UMB_ELEMENT_PROPERTY_VALUE_ENTITY_TYPE, + culture: value.culture || null, + segment: value.segment || null, + alias: value.alias, + value: value.value, + }; + }), + variants: data.variants.map((variant) => { + return { + culture: variant.culture || null, + segment: variant.segment || null, + state: variant.state, + name: variant.name, + publishDate: variant.publishDate || null, + createDate: variant.createDate, + updateDate: variant.updateDate, + scheduledPublishDate: variant.scheduledPublishDate || null, + scheduledUnpublishDate: variant.scheduledUnpublishDate || null, + flags: [], //variant.flags, + }; + }), + documentType: { + unique: data.documentType.id, + collection: null, + }, + isTrashed: data.isTrashed, + flags: data.flags, + }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.store.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.store.context-token.ts new file mode 100644 index 000000000000..149f4a7e69fc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.store.context-token.ts @@ -0,0 +1,4 @@ +import type { UmbElementDetailStore } from './element-detail.store.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_ELEMENT_DETAIL_STORE_CONTEXT = new UmbContextToken('UmbElementDetailStore'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.store.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.store.ts new file mode 100644 index 000000000000..0c50067b0162 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/element-detail.store.ts @@ -0,0 +1,22 @@ +import type { UmbElementDetailModel } from '../../types.js'; +import { UMB_ELEMENT_DETAIL_STORE_CONTEXT } from './element-detail.store.context-token.js'; +import { UmbDetailStoreBase } from '@umbraco-cms/backoffice/store'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +/** + * @class UmbElementDetailStore + * @augments {UmbStoreBase} + * @description - Data Store for Element Details + */ +export class UmbElementDetailStore extends UmbDetailStoreBase { + /** + * Creates an instance of UmbElementDetailStore. + * @param {UmbControllerHost} host - The controller host for this controller to be appended to + * @memberof UmbElementDetailStore + */ + constructor(host: UmbControllerHost) { + super(host, UMB_ELEMENT_DETAIL_STORE_CONTEXT.toString()); + } +} + +export { UmbElementDetailStore as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/index.ts new file mode 100644 index 000000000000..a9fbc85fb252 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/index.ts @@ -0,0 +1 @@ +export { UmbElementDetailRepository } from './element-detail.repository.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/manifests.ts new file mode 100644 index 000000000000..aa2f10def7cf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/detail/manifests.ts @@ -0,0 +1,17 @@ +import { UMB_ELEMENT_DETAIL_REPOSITORY_ALIAS, UMB_ELEMENT_DETAIL_STORE_ALIAS } from './constants.js'; +import { UmbElementDetailStore } from './element-detail.store.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_ELEMENT_DETAIL_REPOSITORY_ALIAS, + name: 'Element Detail Repository', + api: () => import('./element-detail.repository.js'), + }, + { + type: 'store', + alias: UMB_ELEMENT_DETAIL_STORE_ALIAS, + name: 'Element Detail Store', + api: UmbElementDetailStore, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/index.ts new file mode 100644 index 000000000000..8c48fe8e1afd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/index.ts @@ -0,0 +1,4 @@ +export { UmbElementDetailRepository } from './detail/index.js'; +//export { UmbElementItemRepository } from './item/index.js'; + +//export type { UmbElementItemModel, UmbElementItemBaseModel } from './item/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/manifests.ts new file mode 100644 index 000000000000..8d3f0da742f5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/repository/manifests.ts @@ -0,0 +1,4 @@ +import { manifests as detailManifests } from './detail/manifests.js'; +//import { manifests as itemManifests } from './item/manifests.js'; + +export const manifests: Array = [...detailManifests /*, ...itemManifests*/]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/tree/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/constants.ts new file mode 100644 index 000000000000..b9ad82f736ad --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/constants.ts @@ -0,0 +1,2 @@ +export const UMB_ELEMENT_TREE_ALIAS = 'Umb.Tree.Element'; +export const UMB_ELEMENT_TREE_REPOSITORY_ALIAS = 'Umb.Repository.Element.Tree'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element-tree-item.context.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element-tree-item.context.ts new file mode 100644 index 000000000000..795532c8d12e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element-tree-item.context.ts @@ -0,0 +1,25 @@ +import type { UmbElementTreeItemModel, UmbElementTreeRootModel } from './types.js'; +import { UmbDefaultTreeItemContext } from '@umbraco-cms/backoffice/tree'; +import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbElementTreeItemContext extends UmbDefaultTreeItemContext< + UmbElementTreeItemModel, + UmbElementTreeRootModel +> { + // TODO: Provide this together with the EntityContext, ideally this takes part via a extension-type [NL] + #isTrashedContext = new UmbIsTrashedEntityContext(this); + + // TODO: Move to API + readonly isTrashed = this._treeItem.asObservablePart((item) => item?.isTrashed ?? false); + + constructor(host: UmbControllerHost) { + super(host); + + this.observe(this.isTrashed, (isTrashed) => { + this.#isTrashedContext.setIsTrashed(isTrashed); + }); + } +} + +export { UmbElementTreeItemContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element-tree.repository.ts new file mode 100644 index 000000000000..b7f9c4b124aa --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element-tree.repository.ts @@ -0,0 +1,32 @@ +import { UMB_ELEMENT_ROOT_ENTITY_TYPE } from '../entity.js'; +import { UmbElementTreeServerDataSource } from './element.tree.server.data-source.js'; +import type { UmbElementTreeItemModel, UmbElementTreeRootModel } from './types.js'; +import { UmbTreeRepositoryBase } from '@umbraco-cms/backoffice/tree'; +import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbElementTreeRepository + extends UmbTreeRepositoryBase + implements UmbApi +{ + constructor(host: UmbControllerHost) { + super(host, UmbElementTreeServerDataSource); + } + + async requestTreeRoot() { + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); + const hasChildren = treeRootData ? treeRootData.total > 0 : false; + + const data: UmbElementTreeRootModel = { + unique: null, + entityType: UMB_ELEMENT_ROOT_ENTITY_TYPE, + name: '#general_elements', + hasChildren, + isFolder: true, + }; + + return { data }; + } +} + +export { UmbElementTreeRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element-tree.server.request-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element-tree.server.request-manager.ts new file mode 100644 index 000000000000..6881a231c9ba --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element-tree.server.request-manager.ts @@ -0,0 +1,68 @@ +/* eslint-disable local-rules/no-direct-api-import */ + +import { ElementService } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbManagementApiTreeDataRequestManager } from '@umbraco-cms/backoffice/management-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { + ElementTreeItemResponseModel, + PagedElementTreeItemResponseModel, + SubsetElementTreeItemResponseModel, +} from '@umbraco-cms/backoffice/external/backend-api'; +import type { + UmbManagementApiTreeAncestorsOfRequestArgs, + UmbManagementApiTreeChildrenOfRequestArgs, + UmbManagementApiTreeRootItemsRequestArgs, + UmbManagementApiTreeSiblingsFromRequestArgs, +} from '@umbraco-cms/backoffice/management-api'; + +export class UmbManagementApiElementTreeDataRequestManager extends UmbManagementApiTreeDataRequestManager< + ElementTreeItemResponseModel, + UmbManagementApiTreeRootItemsRequestArgs, + PagedElementTreeItemResponseModel, + UmbManagementApiTreeChildrenOfRequestArgs, + PagedElementTreeItemResponseModel, + UmbManagementApiTreeAncestorsOfRequestArgs, + Array, + UmbManagementApiTreeSiblingsFromRequestArgs, + SubsetElementTreeItemResponseModel +> { + constructor(host: UmbControllerHost) { + super(host, { + getRootItems: (args) => + ElementService.getTreeElementRoot({ + query: { + foldersOnly: args.foldersOnly, + skip: args.paging.skip, + take: args.paging.take, + }, + }), + + getChildrenOf: (args) => + ElementService.getTreeElementChildren({ + query: { + parentId: args.parent.unique, + foldersOnly: args.foldersOnly, + skip: args.paging.skip, + take: args.paging.take, + }, + }), + + getAncestorsOf: (args) => + ElementService.getTreeElementAncestors({ + query: { + descendantId: args.treeItem.unique, + }, + }), + + getSiblingsFrom: (args) => + ElementService.getTreeElementSiblings({ + query: { + foldersOnly: args.foldersOnly, + target: args.paging.target.unique, + before: args.paging.takeBefore, + after: args.paging.takeAfter, + }, + }), + }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element.tree.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element.tree.server.data-source.ts new file mode 100644 index 000000000000..a278e8738099 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/element.tree.server.data-source.ts @@ -0,0 +1,88 @@ +import { UMB_ELEMENT_ENTITY_TYPE, UMB_ELEMENT_ROOT_ENTITY_TYPE, UMB_ELEMENT_FOLDER_ENTITY_TYPE } from '../entity.js'; +import type { UmbElementTreeItemModel } from '../types.js'; +import { UmbManagementApiElementTreeDataRequestManager } from './element-tree.server.request-manager.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { ElementTreeItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { + UmbTreeAncestorsOfRequestArgs, + UmbTreeChildrenOfRequestArgs, + UmbTreeDataSource, + UmbTreeRootItemsRequestArgs, +} from '@umbraco-cms/backoffice/tree'; + +/** + * A data source for the Element tree that fetches data from the server + * @class UmbElementTreeServerDataSource + */ +export class UmbElementTreeServerDataSource + extends UmbControllerBase + implements UmbTreeDataSource +{ + #treeRequestManager = new UmbManagementApiElementTreeDataRequestManager(this); + + async getRootItems(args: UmbTreeRootItemsRequestArgs) { + const { data, error } = await this.#treeRequestManager.getRootItems(args); + + const mappedData = data + ? { + ...data, + items: data?.items.map((item) => this.#mapItem(item)), + } + : undefined; + + return { data: mappedData, error }; + } + + async getChildrenOf(args: UmbTreeChildrenOfRequestArgs) { + const { data, error } = await this.#treeRequestManager.getChildrenOf(args); + + const mappedData = data + ? { + ...data, + items: data?.items.map((item) => this.#mapItem(item)), + } + : undefined; + + return { data: mappedData, error }; + } + + async getAncestorsOf(args: UmbTreeAncestorsOfRequestArgs) { + const { data, error } = await this.#treeRequestManager.getAncestorsOf(args); + + const mappedData = data?.map((item) => this.#mapItem(item)); + + return { data: mappedData, error }; + } + + // TODO: Review the commented out properties. [LK:2026-01-14] + #mapItem(item: ElementTreeItemResponseModel): UmbElementTreeItemModel { + return { + unique: item.id, + parent: { + unique: item.parent ? item.parent.id : null, + entityType: item.parent ? UMB_ELEMENT_ENTITY_TYPE : UMB_ELEMENT_ROOT_ENTITY_TYPE, + }, + name: item.name, + entityType: item.isFolder ? UMB_ELEMENT_FOLDER_ENTITY_TYPE : UMB_ELEMENT_ENTITY_TYPE, + hasChildren: item.hasChildren, + isTrashed: false, //item.isTrashed, + isFolder: item.isFolder, + documentType: { + unique: item.documentType?.id ?? '', + icon: item.documentType?.icon ?? 'icon-document', + collection: null, + }, + icon: item.isFolder ? 'icon-folder' : (item.documentType?.icon ?? 'icon-document'), + createDate: item.createDate, + variants: item.variants.map((variant) => { + return { + name: variant.name, + culture: variant.culture || null, + segment: null, // TODO: add segment to the backend API? + state: variant.state, + flags: [], //variant.flags, + }; + }), + }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/tree/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/index.ts new file mode 100644 index 000000000000..0273dec3019b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/index.ts @@ -0,0 +1,2 @@ +export * from './constants.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/tree/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/manifests.ts new file mode 100644 index 000000000000..62624822aaf1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/manifests.ts @@ -0,0 +1,41 @@ +import { UMB_ELEMENT_ENTITY_TYPE, UMB_ELEMENT_ROOT_ENTITY_TYPE } from '../entity.js'; +import { UMB_ELEMENT_TREE_ALIAS, UMB_ELEMENT_TREE_REPOSITORY_ALIAS } from './constants.js'; +import type { ManifestRepository } from '@umbraco-cms/backoffice/extension-registry'; +import type { ManifestTree, ManifestTreeItem } from '@umbraco-cms/backoffice/tree'; + +const repository: ManifestRepository = { + type: 'repository', + alias: UMB_ELEMENT_TREE_REPOSITORY_ALIAS, + name: 'Element Tree Repository', + api: () => import('./element-tree.repository.js'), +}; + +const tree: ManifestTree = { + type: 'tree', + kind: 'default', + alias: UMB_ELEMENT_TREE_ALIAS, + name: 'Element Tree', + meta: { + repositoryAlias: UMB_ELEMENT_TREE_REPOSITORY_ALIAS, + }, +}; + +const treeItems: Array = [ + { + type: 'treeItem', + kind: 'default', + alias: 'Umb.TreeItem.Element', + name: 'Element Tree Item', + api: () => import('./element-tree-item.context.js'), + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + }, + { + type: 'treeItem', + kind: 'default', + alias: 'Umb.TreeItem.Element.Root', + name: 'Element Tree Root', + forEntityTypes: [UMB_ELEMENT_ROOT_ENTITY_TYPE], + }, +]; + +export const manifests: Array = [repository, tree, ...treeItems]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/tree/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/types.ts new file mode 100644 index 000000000000..fca6df89436a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/tree/types.ts @@ -0,0 +1,27 @@ +import type { UmbElementEntityType, UmbElementRootEntityType, UmbElementFolderEntityType } from '../entity.js'; +import type { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; +import type { UmbTreeItemModel, UmbTreeRootModel } from '@umbraco-cms/backoffice/tree'; + +export interface UmbElementTreeItemModel extends UmbTreeItemModel { + entityType: UmbElementEntityType | UmbElementFolderEntityType; + isTrashed: boolean; + documentType: { + unique: string; + icon: string; + collection: UmbReferenceByUnique | null; + }; + createDate: string; + variants: Array; +} + +export interface UmbElementTreeRootModel extends UmbTreeRootModel { + entityType: UmbElementRootEntityType; +} + +export interface UmbElementTreeItemVariantModel { + name: string; + culture: string | null; + segment: string | null; + state: DocumentVariantStateModel; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/types.ts new file mode 100644 index 000000000000..6a7c730717ee --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/types.ts @@ -0,0 +1,34 @@ +import type { UmbElementEntityType } from './entity.js'; +import { DocumentVariantStateModel as UmbElementVariantState } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbContentDetailModel, UmbContentValueModel } from '@umbraco-cms/backoffice/content'; +import type { UmbEntityVariantModel, UmbEntityVariantOptionModel } from '@umbraco-cms/backoffice/variant'; + +export type * from './tree/types.js'; +export type * from './entity.js'; + +export { UmbElementVariantState }; + +export interface UmbElementDetailModel extends UmbContentDetailModel { + documentType: { + unique: string; + collection: null; + }; + entityType: UmbElementEntityType; + unique: string; + isTrashed: boolean; + values: Array; + variants: Array; +} + +export interface UmbElementVariantModel extends UmbEntityVariantModel { + state?: UmbElementVariantState | null; + publishDate?: string | null; + scheduledPublishDate?: string | null; + scheduledUnpublishDate?: string | null; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbElementValueModel extends UmbContentValueModel {} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbElementVariantOptionModel extends UmbEntityVariantOptionModel {} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/umbraco-package.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/umbraco-package.ts new file mode 100644 index 000000000000..83bdaefd5609 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/umbraco-package.ts @@ -0,0 +1,9 @@ +export const name = 'Umbraco.Core.ElementManagement'; +export const extensions = [ + { + name: 'Umbraco Element Management Bundle', + alias: 'Umb.Bundle.ElementManagement', + type: 'bundle', + js: () => import('./manifests.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/constants.ts new file mode 100644 index 000000000000..2f19abf8e00f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/constants.ts @@ -0,0 +1 @@ +export const UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS = 'Umb.Condition.UserPermission.Element'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/element-user-permission.condition.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/element-user-permission.condition.ts new file mode 100644 index 000000000000..1aaa8006fdd3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/element-user-permission.condition.ts @@ -0,0 +1,132 @@ +import type { UmbElementUserPermissionConditionConfig } from './types.js'; +import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; +import { UMB_ANCESTORS_ENTITY_CONTEXT, UMB_ENTITY_CONTEXT, type UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbConditionControllerArguments, UmbExtensionCondition } from '@umbraco-cms/backoffice/extension-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { ElementPermissionPresentationModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbConditionBase } from '@umbraco-cms/backoffice/extension-registry'; + +export class UmbElementUserPermissionCondition + extends UmbConditionBase + implements UmbExtensionCondition +{ + #entityType: string | undefined; + #unique: string | null | undefined; + #elementPermissions: Array = []; + #fallbackPermissions: string[] = []; + #ancestors: Array = []; + + constructor(host: UmbControllerHost, args: UmbConditionControllerArguments) { + super(host, args); + + this.consumeContext(UMB_CURRENT_USER_CONTEXT, (context) => { + this.observe( + context?.currentUser, + (currentUser) => { + this.#elementPermissions = currentUser?.permissions?.filter(this.#isElementUserPermission) || []; + this.#fallbackPermissions = currentUser?.fallbackPermissions || []; + this.#checkPermissions(); + }, + 'umbUserPermissionConditionObserver', + ); + }); + + this.consumeContext(UMB_ENTITY_CONTEXT, (context) => { + if (!context) { + this.removeUmbControllerByAlias('umbUserPermissionEntityContextObserver'); + return; + } + + this.observe( + observeMultiple([context.entityType, context.unique]), + ([entityType, unique]) => { + this.#entityType = entityType; + this.#unique = unique; + this.#checkPermissions(); + }, + 'umbUserPermissionEntityContextObserver', + ); + }); + + this.consumeContext(UMB_ANCESTORS_ENTITY_CONTEXT, (instance) => { + this.observe( + instance?.ancestors, + (ancestors) => { + this.#ancestors = ancestors?.map((item) => item.unique) ?? []; + this.#checkPermissions(); + }, + 'observeAncestors', + ); + }); + } + + #checkPermissions() { + if (!this.#entityType) return; + if (this.#unique === undefined) return; + + const hasElementPermissions = this.#elementPermissions.length > 0; + + // if there is no permissions for any elements we use the fallback permissions + if (!hasElementPermissions) { + this.#check(this.#fallbackPermissions); + return; + } + + // If there are element permissions, we need to check the full path to see if any permissions are defined for the current element + // If we find multiple permissions in the same path, we will apply the closest one + if (hasElementPermissions) { + // Path including the current element and all ancestors + const path = [...this.#ancestors, this.#unique].filter((unique) => unique !== null); + // Reverse the path to find the closest element permission quickly + const reversedPath = [...path].reverse(); + const elementPermissionsMap = new Map(this.#elementPermissions.map((p) => [p.element.id, p])); + + // Find the closest element permission in the path + const closestElementPermission = reversedPath.find((id) => elementPermissionsMap.has(id)); + + // Retrieve the corresponding permission data + const match = closestElementPermission ? elementPermissionsMap.get(closestElementPermission) : undefined; + + // no permissions for the current element - use the fallback permissions + if (!match) { + this.#check(this.#fallbackPermissions); + return; + } + + // we found permissions - check them + this.#check(match.verbs); + } + } + + #check(verbs: Array) { + /* we default to true se we don't require both allOf and oneOf to be defined + but they can be combined for more complex scenarios */ + let allOfPermitted = true; + let oneOfPermitted = true; + + // check if all of the verbs are present + if (this.config.allOf?.length) { + allOfPermitted = this.config.allOf.every((verb) => verbs.includes(verb)); + } + + // check if at least one of the verbs is present + if (this.config.oneOf?.length) { + oneOfPermitted = this.config.oneOf.some((verb) => verbs.includes(verb)); + } + + // if neither allOf or oneOf is defined we default to false + if (!allOfPermitted && !oneOfPermitted) { + allOfPermitted = false; + oneOfPermitted = false; + } + + this.permitted = allOfPermitted && oneOfPermitted; + } + + #isElementUserPermission(permission: unknown): permission is ElementPermissionPresentationModel { + return (permission as ElementPermissionPresentationModel).$type === 'ElementPermissionPresentationModel'; + } +} + +export { UmbElementUserPermissionCondition as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/index.ts new file mode 100644 index 000000000000..7d81657be568 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/index.ts @@ -0,0 +1 @@ +export { UmbElementUserPermissionCondition } from './element-user-permission.condition.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/manifests.ts new file mode 100644 index 000000000000..1fbce7d90895 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/manifests.ts @@ -0,0 +1,11 @@ +import { UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS } from './constants.js'; +import { UmbElementUserPermissionCondition } from './element-user-permission.condition.js'; + +export const manifests: Array = [ + { + type: 'condition', + name: 'Element User Permission Condition', + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + api: UmbElementUserPermissionCondition, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/types.ts new file mode 100644 index 000000000000..dd7cfbefff97 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/conditions/types.ts @@ -0,0 +1,23 @@ +import type { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-api'; + +export type UmbElementUserPermissionConditionConfig = UmbConditionConfigBase<'Umb.Condition.UserPermission.Element'> & { + /** + * The user must have all of the permissions in this array for the condition to be met. + * @example + * ["Umb.Element.Save", "Umb.Element.Publish"] + */ + allOf?: Array; + + /** + * The user must have at least one of the permissions in this array for the condition to be met. + * @example + * ["Umb.Element.Save", "Umb.Element.Publish"] + */ + oneOf?: Array; +}; + +declare global { + interface UmbExtensionConditionConfigMap { + UmbElementUserPermissionConditionConfig: UmbElementUserPermissionConditionConfig; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/constants.ts new file mode 100644 index 000000000000..287af9dd3d13 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/constants.ts @@ -0,0 +1,11 @@ +export const UMB_USER_PERMISSION_ELEMENT_CREATE = 'Umb.Element.Create'; +export const UMB_USER_PERMISSION_ELEMENT_DELETE = 'Umb.Element.Delete'; +export const UMB_USER_PERMISSION_ELEMENT_DUPLICATE = 'Umb.Element.Duplicate'; +export const UMB_USER_PERMISSION_ELEMENT_MOVE = 'Umb.Element.Move'; +export const UMB_USER_PERMISSION_ELEMENT_PUBLISH = 'Umb.Element.Publish'; +export const UMB_USER_PERMISSION_ELEMENT_READ = 'Umb.Element.Read'; +export const UMB_USER_PERMISSION_ELEMENT_ROLLBACK = 'Umb.Element.Rollback'; +export const UMB_USER_PERMISSION_ELEMENT_UNPUBLISH = 'Umb.Element.Unpublish'; +export const UMB_USER_PERMISSION_ELEMENT_UPDATE = 'Umb.Element.Update'; + +export * from './conditions/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/input-element-granular-user-permission.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/input-element-granular-user-permission.element.ts new file mode 100644 index 000000000000..a1dc474d279e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/input-element-granular-user-permission.element.ts @@ -0,0 +1,278 @@ +import { UmbElementItemRepository } from '../item/repository/element-item.repository.js'; +import { UMB_ELEMENT_PICKER_MODAL } from '../modals/element-picker-modal.token.js'; +import type { UmbElementItemModel } from '../item/repository/types.js'; +import type { UmbElementTreeItemModel } from '../tree/types.js'; +import type { UmbElementUserPermissionModel } from './types.js'; +import { css, customElement, html, property, repeat, state } from '@umbraco-cms/backoffice/external/lit'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbChangeEvent, UmbSelectedEvent } from '@umbraco-cms/backoffice/event'; +import { UMB_ENTITY_USER_PERMISSION_MODAL } from '@umbraco-cms/backoffice/user-permission'; +import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import type { ManifestEntityUserPermission } from '@umbraco-cms/backoffice/user-permission'; +import type { UmbDeselectedEvent } from '@umbraco-cms/backoffice/event'; +import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; + +@customElement('umb-input-element-granular-user-permission') +export class UmbInputElementGranularUserPermissionElement extends UUIFormControlMixin(UmbLitElement, '') { + #permissions: Array = []; + public get permissions(): Array { + return this.#permissions; + } + public set permissions(value: Array) { + this.#permissions = value; + const uniques = value.map((item) => item.element.id); + this.#observePickedElements(uniques); + } + + @property({ type: Array, attribute: false }) + public fallbackPermissions: Array = []; + + @state() + private _items?: Array; + + #elementItemRepository = new UmbElementItemRepository(this); + #modalManagerContext?: UmbModalManagerContext; + #elementPickerModalContext?: any; + #entityUserPermissionModalContext?: any; + + constructor() { + super(); + + this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => (this.#modalManagerContext = instance)); + } + + protected override getFormElement() { + return undefined; + } + + async #observePickedElements(uniques: Array) { + const { asObservable } = await this.#elementItemRepository.requestItems(uniques); + this.observe( + asObservable?.(), + (items) => { + this._items = items; + }, + 'observeItems', + ); + } + + async #editGranularPermission(item: UmbElementItemModel) { + const currentPermissionVerbs = this.#getPermissionForElement(item.unique)?.verbs ?? []; + const result = await this.#selectEntityUserPermissionsForElement(item, currentPermissionVerbs); + // don't do anything if the verbs have not been updated + if (JSON.stringify(result) === JSON.stringify(currentPermissionVerbs)) return; + + // update permission with new verbs + this.permissions = this.#permissions.map((permission) => { + if (permission.element.id === item.unique) { + return { + ...permission, + verbs: result, + }; + } + return permission; + }); + + this.dispatchEvent(new UmbChangeEvent()); + } + + async #addGranularPermission() { + this.#elementPickerModalContext = this.#modalManagerContext?.open(this, UMB_ELEMENT_PICKER_MODAL, { + data: { + hideTreeRoot: true, + // prevent already selected items to be picked again + pickableFilter: (treeItem: UmbElementTreeItemModel) => + !treeItem.isFolder && !this._items?.map((i) => i.unique).includes(treeItem.unique), + }, + }); + + this.#elementPickerModalContext?.addEventListener(UmbSelectedEvent.TYPE, async (event: UmbDeselectedEvent) => { + const selectedEvent = event as UmbSelectedEvent; + const unique = selectedEvent.unique; + if (!unique) return; + + const elementItem = await this.#requestElementItem(unique); + + this.#selectEntityUserPermissionsForElement(elementItem).then( + (result) => { + this.#elementPickerModalContext?.reject(); + + const permissionItem: UmbElementUserPermissionModel = { + $type: 'ElementPermissionPresentationModel', + element: { id: unique }, + verbs: result, + }; + + this.permissions = [...this.#permissions, permissionItem]; + this.dispatchEvent(new UmbChangeEvent()); + }, + () => { + this.#elementPickerModalContext?.reject(); + }, + ); + }); + } + + async #requestElementItem(unique: string) { + if (!unique) throw new Error('Could not open permissions modal, no unique was provided'); + + const { data } = await this.#elementItemRepository.requestItems([unique]); + + const elementItem = data?.[0]; + if (!elementItem) throw new Error('No element item found'); + return elementItem; + } + + async #selectEntityUserPermissionsForElement(item: UmbElementItemModel, allowedVerbs: Array = []) { + // TODO: get correct variant name + const name = item.variants[0]?.name; + const headline = name ? `Permissions for ${name}` : 'Permissions'; + const fallbackVerbs = this.#getFallbackPermissionVerbsForEntityType(item.entityType); + const value = allowedVerbs.length > 0 ? { allowedVerbs } : undefined; + this.#entityUserPermissionModalContext = this.#modalManagerContext?.open(this, UMB_ENTITY_USER_PERMISSION_MODAL, { + data: { + entityType: item.entityType, + headline, + preset: { + allowedVerbs: fallbackVerbs, + }, + }, + value, + }); + + try { + // When the modal is submitted we return the new value from the modal + const value = await this.#entityUserPermissionModalContext?.onSubmit(); + return value?.allowedVerbs; + } catch { + // When the modal is rejected we return the current value + return allowedVerbs; + } + } + + #removeGranularPermission(item: UmbElementItemModel) { + const permission = this.#getPermissionForElement(item.unique); + if (!permission) return; + + this.permissions = this.#permissions.filter((v) => JSON.stringify(v) !== JSON.stringify(permission)); + this.dispatchEvent(new UmbChangeEvent()); + } + + override render() { + return html`${this.#renderItems()} ${this.#renderAddButton()}`; + } + + #renderItems() { + if (!this._items) return; + return html` + + ${repeat( + this._items, + (item) => item.unique, + (item) => this.#renderRef(item), + )} + + `; + } + + #renderAddButton() { + return html``; + } + + #renderRef(item: UmbElementItemModel) { + if (!item.unique) return; + // TODO: get correct variant name + const name = item.variants[0]?.name; + const permissionNames = this.#getPermissionNamesForElement(item.unique); + + return html` + + ${this.#renderIcon(item)} ${this.#renderIsTrashed(item)} + + ${this.#renderEditButton(item)} ${this.#renderRemoveButton(item)} + + + `; + } + + #renderIcon(item: UmbElementItemModel) { + if (!item.documentType.icon) return; + return html``; + } + + #renderIsTrashed(item: UmbElementItemModel) { + if (!item.isTrashed) return; + return html`Trashed`; + } + + #renderEditButton(item: UmbElementItemModel) { + return html` + this.#editGranularPermission(item)} + label=${this.localize.term('general_edit')}> + `; + } + + #renderRemoveButton(item: UmbElementItemModel) { + return html` this.#removeGranularPermission(item)} + label=${this.localize.term('general_remove')}>`; + } + + #getPermissionForElement(unique: string) { + return this.#permissions?.find((permission) => permission.element.id === unique); + } + + #getPermissionNamesForElement(unique: string) { + const permission = this.#getPermissionForElement(unique); + if (!permission) return; + + return umbExtensionsRegistry + .getByTypeAndFilter('entityUserPermission', (manifest) => + manifest.meta.verbs.every((verb) => permission.verbs.includes(verb)), + ) + .map((m) => { + const manifest = m as ManifestEntityUserPermission; + return manifest.meta.label ? this.localize.string(manifest.meta.label) : manifest.name; + }) + .join(', '); + } + + #getFallbackPermissionVerbsForEntityType(entityType: string) { + // get all permissions that are allowed for the entity type and have at least one of the fallback permissions + // this is used to determine the default permissions for a element + const verbs = umbExtensionsRegistry + .getByTypeAndFilter( + 'entityUserPermission', + (manifest) => + manifest.forEntityTypes.includes(entityType) && + this.fallbackPermissions.map((verb) => manifest.meta.verbs.includes(verb)).includes(true), + ) + .flatMap((permission) => permission.meta.verbs); + + // ensure that the verbs are unique + return [...new Set([...verbs])]; + } + + static override styles = [ + css` + #btn-add { + width: 100%; + } + `, + ]; +} + +export default UmbInputElementGranularUserPermissionElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-element-granular-user-permission': UmbInputElementGranularUserPermissionElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/manifests.ts new file mode 100644 index 000000000000..2db83080839f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/manifests.ts @@ -0,0 +1,143 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../entity.js'; +import { + UMB_USER_PERMISSION_ELEMENT_CREATE, + UMB_USER_PERMISSION_ELEMENT_DELETE, + UMB_USER_PERMISSION_ELEMENT_DUPLICATE, + UMB_USER_PERMISSION_ELEMENT_MOVE, + UMB_USER_PERMISSION_ELEMENT_PUBLISH, + UMB_USER_PERMISSION_ELEMENT_READ, + UMB_USER_PERMISSION_ELEMENT_ROLLBACK, + UMB_USER_PERMISSION_ELEMENT_UNPUBLISH, + UMB_USER_PERMISSION_ELEMENT_UPDATE, +} from './constants.js'; +import { manifests as conditions } from './conditions/manifests.js'; +import type { + ManifestEntityUserPermission, + ManifestGranularUserPermission, +} from '@umbraco-cms/backoffice/user-permission'; + +const entityUserPermissions: Array = [ + { + type: 'entityUserPermission', + alias: 'Umb.EntityUserPermission.Element.Create', + name: 'Create Element User Permission', + weight: 90, + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + verbs: [UMB_USER_PERMISSION_ELEMENT_CREATE], + label: '#userPermissions_create', + description: '#userPermissions_create_element', + }, + }, + { + type: 'entityUserPermission', + alias: 'Umb.EntityUserPermission.Element.Delete', + name: 'Delete Element User Permission', + weight: 80, + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + verbs: [UMB_USER_PERMISSION_ELEMENT_DELETE], + label: '#userPermissions_delete', + description: '#userPermissions_delete_element', + }, + }, + { + type: 'entityUserPermission', + alias: 'Umb.EntityUserPermission.Element.Duplicate', + name: 'Duplicate Element User Permission', + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + verbs: [UMB_USER_PERMISSION_ELEMENT_DUPLICATE], + label: '#userPermissions_duplicate', + description: '#userPermissions_duplicate_element', + group: 'structure', + }, + }, + { + type: 'entityUserPermission', + alias: 'Umb.EntityUserPermission.Element.Move', + name: 'Move Element User Permission', + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + verbs: [UMB_USER_PERMISSION_ELEMENT_MOVE], + label: '#userPermissions_move', + description: '#userPermissions_move_element', + group: 'structure', + }, + }, + { + type: 'entityUserPermission', + alias: 'Umb.EntityUserPermission.Element.Publish', + name: 'Publish Element User Permission', + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + verbs: [UMB_USER_PERMISSION_ELEMENT_PUBLISH], + label: '#userPermissions_publish', + description: '#userPermissions_publish_element', + }, + }, + { + type: 'entityUserPermission', + alias: 'Umb.EntityUserPermission.Element.Read', + name: 'Read Element User Permission', + weight: 100, + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + verbs: [UMB_USER_PERMISSION_ELEMENT_READ], + label: '#userPermissions_read', + description: '#userPermissions_read_element', + }, + }, + { + type: 'entityUserPermission', + alias: 'Umb.EntityUserPermission.Element.Rollback', + name: 'Rollback Element User Permission', + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + verbs: [UMB_USER_PERMISSION_ELEMENT_ROLLBACK], + label: '#userPermissions_rollback', + description: '#userPermissions_rollback_element', + group: 'administration', + }, + }, + { + type: 'entityUserPermission', + alias: 'Umb.EntityUserPermission.Element.Unpublish', + name: 'Unpublish Element User Permission', + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + verbs: [UMB_USER_PERMISSION_ELEMENT_UNPUBLISH], + label: '#userPermissions_unpublish', + description: '#userPermissions_unpublish_element', + }, + }, + { + type: 'entityUserPermission', + alias: 'Umb.EntityUserPermission.Element.Update', + name: 'Update Element User Permission', + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + meta: { + verbs: [UMB_USER_PERMISSION_ELEMENT_UPDATE], + label: '#userPermissions_update', + description: '#userPermissions_update_element', + }, + }, +]; + +const granularPermissions: Array = [ + { + type: 'userGranularPermission', + alias: 'Umb.UserGranularPermission.Element', + name: 'Element Granular User Permission', + weight: 1000, + forEntityTypes: [UMB_ELEMENT_ENTITY_TYPE], + element: () => import('./input-element-granular-user-permission.element.js'), + meta: { + schemaType: 'ElementPermissionPresentationModel', + label: '#user_permissionsGranular', + description: '{#userPermissions_granular_element}', + }, + }, +]; + +export const manifests: Array = [...conditions, ...entityUserPermissions, ...granularPermissions]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/types.ts new file mode 100644 index 000000000000..3f8c8f305c38 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/user-permissions/types.ts @@ -0,0 +1,6 @@ +import type { UmbUserPermissionModel } from '@umbraco-cms/backoffice/user-permission'; +export type * from './conditions/types.js'; +export interface UmbElementUserPermissionModel extends UmbUserPermissionModel { + // TODO: this should be unique instead of an id, but we currently have no way to map a mixed server response. + element: { id: string }; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/vite.config.ts new file mode 100644 index 000000000000..6eedab42aae7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/elements'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ dist }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/constants.ts new file mode 100644 index 000000000000..2deae15c545d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/constants.ts @@ -0,0 +1,4 @@ +export * from './element-root/constants.js'; + +export const UMB_ELEMENT_WORKSPACE_ALIAS = 'Umb.Workspace.Element'; +export { UMB_ELEMENT_WORKSPACE_CONTEXT } from './element-workspace.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-menu-structure.context.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-menu-structure.context.ts new file mode 100644 index 000000000000..005493aa3ef2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-menu-structure.context.ts @@ -0,0 +1,23 @@ +import { UMB_ELEMENT_TREE_REPOSITORY_ALIAS } from '../tree/index.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { + UmbMenuVariantTreeStructureWorkspaceContextBase, + type UmbVariantStructureItemModel, +} from '@umbraco-cms/backoffice/menu'; + +export class UmbElementMenuStructureContext extends UmbMenuVariantTreeStructureWorkspaceContextBase { + constructor(host: UmbControllerHost) { + super(host, { treeRepositoryAlias: UMB_ELEMENT_TREE_REPOSITORY_ALIAS }); + } + + override getItemHref(structureItem: UmbVariantStructureItemModel): string | undefined { + // The Element menu does not have a root item, so we do not have a href for it. + if (!structureItem.unique) { + return `section/${this._sectionContext?.getPathname()}`; + } else { + return super.getItemHref(structureItem); + } + } +} + +export default UmbElementMenuStructureContext; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-root/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-root/constants.ts new file mode 100644 index 000000000000..b00cd5287976 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-root/constants.ts @@ -0,0 +1 @@ +export const UMB_ELEMENT_ROOT_WORKSPACE_ALIAS = 'Umb.Workspace.Element.Root'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-root/index.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-root/index.ts new file mode 100644 index 000000000000..4f07201dcf0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-root/index.ts @@ -0,0 +1 @@ +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-root/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-root/manifests.ts new file mode 100644 index 000000000000..c220d146e694 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-root/manifests.ts @@ -0,0 +1,15 @@ +import { UMB_ELEMENT_ROOT_ENTITY_TYPE } from '../../entity.js'; +import { UMB_ELEMENT_ROOT_WORKSPACE_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'workspace', + kind: 'default', + alias: UMB_ELEMENT_ROOT_WORKSPACE_ALIAS, + name: 'Element Root Workspace', + meta: { + entityType: UMB_ELEMENT_ROOT_ENTITY_TYPE, + headline: '#general_elements', + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace-editor.element.ts new file mode 100644 index 000000000000..26a6971ec3b5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace-editor.element.ts @@ -0,0 +1,154 @@ +import type { UmbElementVariantOptionModel } from '../types.js'; +import { UmbElementWorkspaceSplitViewElement } from './element-workspace-split-view.element.js'; +import { UMB_ELEMENT_WORKSPACE_CONTEXT } from './element-workspace.context-token.js'; +import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import type { UmbRoute, UmbRouterSlotInitEvent } from '@umbraco-cms/backoffice/router'; + +@customElement('umb-element-workspace-editor') +export class UmbElementWorkspaceEditorElement extends UmbLitElement { + // + // TODO: Refactor: when having a split view/variants context token, we can rename the split view/variants component to a generic and make this component generic as well. [NL] + private _splitViewElement = new UmbElementWorkspaceSplitViewElement(); + + #workspaceContext?: typeof UMB_ELEMENT_WORKSPACE_CONTEXT.TYPE; + #variants?: Array; + #isForbidden = false; + + @state() + private _routes?: Array; + + @state() + private _loading?: boolean = true; + + constructor() { + super(); + this.consumeContext(UMB_ELEMENT_WORKSPACE_CONTEXT, (instance) => { + this.#workspaceContext = instance; + this.#observeVariants(); + this.#observeForbidden(); + this.#observeLoading(); + }); + } + + #observeVariants() { + if (!this.#workspaceContext) return; + this.observe( + this.#workspaceContext.variantOptions, + (variants) => { + this.#variants = variants; + this._generateRoutes(); + }, + '_observeVariants', + ); + } + + #observeForbidden() { + this.observe( + this.#workspaceContext?.forbidden.isOn, + (isForbidden) => { + this.#isForbidden = isForbidden ?? false; + this._generateRoutes(); + }, + '_observeForbidden', + ); + } + + #observeLoading() { + this.observe( + this.#workspaceContext?.loading.isOn, + (loading) => { + this._loading = loading ?? false; + }, + '_observeLoading', + ); + } + + private _generateRoutes() { + // Generate split view routes for all available routes + const routes: Array = []; + + // Split view routes: + this.#variants?.forEach((variantA) => { + this.#variants?.forEach((variantB) => { + routes.push({ + // TODO: When implementing Segments, be aware if using the unique is URL Safe... [NL] + path: variantA.unique + '_&_' + variantB.unique, + component: this._splitViewElement, + setup: (_component, info) => { + // Set split view/active info.. + this.#workspaceContext?.splitView.setVariantParts(info.match.fragments.consumed); + }, + }); + }); + }); + + // Single view: + this.#variants?.forEach((variant) => { + routes.push({ + // TODO: When implementing Segments, be aware if using the unique is URL Safe... [NL] + path: variant.unique, + component: this._splitViewElement, + setup: (_component, info) => { + // cause we might come from a split-view, we need to reset index 1. + this.#workspaceContext?.splitView.removeActiveVariant(1); + this.#workspaceContext?.splitView.handleVariantFolderPart(0, info.match.fragments.consumed); + }, + }); + }); + + if (routes.length !== 0 && this.#variants?.length) { + // Using first single view as the default route for now (hence the math below): + routes.push({ + path: '', + pathMatch: 'full', + redirectTo: routes[this.#variants.length * this.#variants.length]?.path, + }); + } + + routes.push({ + path: `**`, + component: async () => { + const router = await import('@umbraco-cms/backoffice/router'); + return this.#isForbidden ? router.UmbRouteForbiddenElement : router.UmbRouteNotFoundElement; + }, + }); + + this._routes = routes; + } + + private _gotWorkspaceRoute = (e: UmbRouterSlotInitEvent) => { + this.#workspaceContext?.splitView.setWorkspaceRoute(e.target.absoluteRouterPath); + }; + + override render() { + return !this._loading && this._routes + ? html`` + : html``; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + width: 100%; + height: 100%; + + --uui-color-invalid: var(--uui-color-warning); + --uui-color-invalid-emphasis: var(--uui-color-warning-emphasis); + --uui-color-invalid-standalone: var(--uui-color-warning-standalone); + --uui-color-invalid-contrast: var(--uui-color-warning-contrast); + } + `, + ]; +} + +export default UmbElementWorkspaceEditorElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-workspace-editor': UmbElementWorkspaceEditorElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace-split-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace-split-view.element.ts new file mode 100644 index 000000000000..4faeb5eab99b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace-split-view.element.ts @@ -0,0 +1,85 @@ +import { UMB_ELEMENT_WORKSPACE_ALIAS, UMB_ELEMENT_WORKSPACE_CONTEXT } from './constants.js'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { css, html, nothing, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbActiveVariant } from '@umbraco-cms/backoffice/workspace'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; + +@customElement('umb-element-workspace-split-view') +export class UmbElementWorkspaceSplitViewElement extends UmbLitElement { + // TODO: Refactor: use the split view context token: + private _workspaceContext?: typeof UMB_ELEMENT_WORKSPACE_CONTEXT.TYPE; + + @state() + private _variants?: Array; + + constructor() { + super(); + // TODO: Refactor: use a split view workspace context token: + this.consumeContext(UMB_ELEMENT_WORKSPACE_CONTEXT, (context) => { + this._workspaceContext = context; + this._observeActiveVariantInfo(); + }); + } + + private _observeActiveVariantInfo() { + if (!this._workspaceContext) return; + this.observe( + this._workspaceContext.splitView.activeVariantsInfo, + (variants) => { + this._variants = variants; + }, + '_observeActiveVariantsInfo', + ); + } + + override render() { + return this._variants + ? html`
+ ${repeat( + this._variants, + (view) => + view.index + '_' + (view.culture ?? '') + '_' + (view.segment ?? '') + '_' + this._variants!.length, + (view) => html` + + `, + )} +
+ + ` + : nothing; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + width: 100%; + height: 100%; + + display: flex; + flex: 1; + flex-direction: column; + } + + #splitViews { + display: flex; + width: 100%; + height: calc(100% - var(--umb-footer-layout-height)); + } + + #breadcrumbs { + margin: 0 var(--uui-size-layout-1); + } + `, + ]; +} + +export default UmbElementWorkspaceSplitViewElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-workspace-split-view': UmbElementWorkspaceSplitViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace.context-token.ts new file mode 100644 index 000000000000..96a5a53b0ce0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace.context-token.ts @@ -0,0 +1,13 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../entity.js'; +import type { UmbElementWorkspaceContext } from './element-workspace.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; +import type { UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; + +export const UMB_ELEMENT_WORKSPACE_CONTEXT = new UmbContextToken< + UmbSubmittableWorkspaceContext, + UmbElementWorkspaceContext +>( + 'UmbWorkspaceContext', + undefined, + (context): context is UmbElementWorkspaceContext => context.getEntityType?.() === UMB_ELEMENT_ENTITY_TYPE, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace.context.ts new file mode 100644 index 000000000000..8ac45dc4e9fb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/element-workspace.context.ts @@ -0,0 +1,187 @@ +import { UMB_CREATE_ELEMENT_WORKSPACE_PATH_PATTERN, UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN } from '../paths.js'; +import { UMB_ELEMENT_ENTITY_TYPE } from '../entity.js'; +import { UMB_ELEMENT_DETAIL_REPOSITORY_ALIAS } from '../repository/detail/constants.js'; +import type { UmbElementDetailRepository } from '../repository/index.js'; +import type { UmbElementDetailModel, UmbElementVariantModel } from '../types.js'; +import { UmbElementWorkspacePropertyDatasetContext } from './property-dataset-context/element-workspace-property-dataset-context.js'; +import { UMB_ELEMENT_WORKSPACE_ALIAS } from './constants.js'; +import { + UmbWorkspaceIsNewRedirectController, + UmbWorkspaceIsNewRedirectControllerAlias, +} from '@umbraco-cms/backoffice/workspace'; +import { UmbDocumentTypeDetailRepository } from '@umbraco-cms/backoffice/document-type'; +import { UmbContentDetailWorkspaceContextBase } from '@umbraco-cms/backoffice/content'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; +import { UMB_DOCUMENT_DETAIL_MODEL_VARIANT_SCAFFOLD } from '@umbraco-cms/backoffice/document'; +import type { UmbContentWorkspaceContext } from '@umbraco-cms/backoffice/content'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbDocumentTypeDetailModel } from '@umbraco-cms/backoffice/document-type'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import { + UmbEntityRestoredFromRecycleBinEvent, + UmbEntityTrashedEvent, + UmbIsTrashedEntityContext, +} from '@umbraco-cms/backoffice/recycle-bin'; +import type { UmbVariantGuardRule } from '@umbraco-cms/backoffice/utils'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; + +type ContentModel = UmbElementDetailModel; +type ContentTypeModel = UmbDocumentTypeDetailModel; + +export class UmbElementWorkspaceContext + extends UmbContentDetailWorkspaceContextBase< + ContentModel, + UmbElementDetailRepository, + ContentTypeModel, + UmbElementVariantModel + > + implements UmbContentWorkspaceContext +{ + readonly contentTypeUnique = this._data.createObservablePartOfCurrent((data) => data?.documentType.unique); + + readonly isTrashed = this._data.createObservablePartOfCurrent((data) => data?.isTrashed); + + #actionEventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; + + #isTrashedContext = new UmbIsTrashedEntityContext(this); + + constructor(host: UmbControllerHost) { + super(host, { + entityType: UMB_ELEMENT_ENTITY_TYPE, + workspaceAlias: UMB_ELEMENT_WORKSPACE_ALIAS, + detailRepositoryAlias: UMB_ELEMENT_DETAIL_REPOSITORY_ALIAS, + contentTypeDetailRepository: UmbDocumentTypeDetailRepository, + contentVariantScaffold: UMB_DOCUMENT_DETAIL_MODEL_VARIANT_SCAFFOLD, + contentTypePropertyName: 'documentType', + ignoreValidationResultOnSubmit: true, + }); + + this.consumeContext(UMB_ACTION_EVENT_CONTEXT, (actionEventContext) => { + this.#removeEventListeners(); + this.#actionEventContext = actionEventContext; + this.#addEventListeners(); + }); + + this.observe( + this.contentTypeUnique, + (unique) => { + if (unique) { + this.structure.loadType(unique); + } + }, + null, + ); + + this.observe(this.isTrashed, (isTrashed) => this.#onTrashStateChange(isTrashed)); + + this.routes.setRoutes([ + { + path: UMB_CREATE_ELEMENT_WORKSPACE_PATH_PATTERN.toString(), + component: () => import('./element-workspace-editor.element.js'), + setup: async (_component, info) => { + const parentEntityType = info.match.params.parentEntityType; + const parentUnique = info.match.params.parentUnique === 'null' ? null : info.match.params.parentUnique; + const documentTypeUnique = info.match.params.documentTypeUnique; + await this.create({ entityType: parentEntityType, unique: parentUnique }, documentTypeUnique); + + new UmbWorkspaceIsNewRedirectController( + this, + this, + this.getHostElement().shadowRoot!.querySelector('umb-router-slot')!, + ); + }, + }, + { + path: UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN.toString(), + component: () => import('./element-workspace-editor.element.js'), + setup: (_component, info) => { + this.removeUmbControllerByAlias(UmbWorkspaceIsNewRedirectControllerAlias); + const unique = info.match.params.unique; + this.load(unique); + }, + }, + ]); + } + + async create(parent: UmbEntityModel, documentTypeUnique: string) { + return this.createScaffold({ + parent, + preset: { + documentType: { + unique: documentTypeUnique, + collection: null, + }, + }, + }); + } + + /** + * Gets the unique identifier of the content type. + * @returns { string | undefined} The unique identifier of the content type. + * @memberof UmbElementWorkspaceContext + */ + getContentTypeUnique(): string | undefined { + return this.getData()?.documentType.unique; + } + + public createPropertyDatasetContext( + host: UmbControllerHost, + variantId: UmbVariantId, + ): UmbElementWorkspacePropertyDatasetContext { + return new UmbElementWorkspacePropertyDatasetContext(host, this, variantId); + } + + override resetState(): void { + super.resetState(); + this.#isTrashedContext.setIsTrashed(false); + } + + #addEventListeners() { + this.#actionEventContext?.addEventListener(UmbEntityTrashedEvent.TYPE, this.#onRecycleBinEvent as EventListener); + this.#actionEventContext?.addEventListener( + UmbEntityRestoredFromRecycleBinEvent.TYPE, + this.#onRecycleBinEvent as EventListener, + ); + } + + #removeEventListeners() { + this.#actionEventContext?.removeEventListener(UmbEntityTrashedEvent.TYPE, this.#onRecycleBinEvent as EventListener); + this.#actionEventContext?.removeEventListener( + UmbEntityRestoredFromRecycleBinEvent.TYPE, + this.#onRecycleBinEvent as EventListener, + ); + } + + #onRecycleBinEvent = (event: UmbEntityTrashedEvent | UmbEntityRestoredFromRecycleBinEvent) => { + const unique = this.getUnique(); + const entityType = this.getEntityType(); + if (event.getUnique() !== unique || event.getEntityType() !== entityType) return; + this.reload(); + }; + + #onTrashStateChange(isTrashed?: boolean) { + this.#isTrashedContext.setIsTrashed(isTrashed ?? false); + + const guardUnique = `UMB_PREVENT_EDIT_TRASHED_ITEM`; + + if (!isTrashed) { + this.readOnlyGuard.removeRule(guardUnique); + return; + } + + const rule: UmbVariantGuardRule = { + unique: guardUnique, + permitted: true, + }; + + // TODO: Change to use property write guard when it supports making the name read-only. + this.readOnlyGuard.addRule(rule); + } + + public override destroy(): void { + this.#removeEventListeners(); + super.destroy(); + } +} + +export { UmbElementWorkspaceContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/manifests.ts new file mode 100644 index 000000000000..6b2e59e1cf05 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/manifests.ts @@ -0,0 +1,137 @@ +import { UMB_ELEMENT_MENU_ITEM_ALIAS } from '../menu/constants.js'; +import { UMB_ELEMENT_ENTITY_TYPE } from '../entity.js'; +import { + UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + UMB_USER_PERMISSION_ELEMENT_UPDATE, +} from '../user-permissions/constants.js'; +import { UMB_ELEMENT_WORKSPACE_ALIAS } from './constants.js'; +import { manifests as elementRoot } from './element-root/manifests.js'; +import { UmbSubmitWorkspaceAction, UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; +import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; +import type { ManifestWorkspaceContextMenuStructureKind } from '@umbraco-cms/backoffice/menu'; +import type { + ManifestWorkspaceAction, + ManifestWorkspaceFooterApp, + ManifestWorkspaceRoutableKind, + ManifestWorkspaceView, +} from '@umbraco-cms/backoffice/workspace'; + +const workspace: ManifestWorkspaceRoutableKind = { + type: 'workspace', + kind: 'routable', + alias: UMB_ELEMENT_WORKSPACE_ALIAS, + name: 'Element Workspace', + api: () => import('./element-workspace.context.js'), + meta: { + entityType: UMB_ELEMENT_ENTITY_TYPE, + }, +}; + +const menuStructure: ManifestWorkspaceContextMenuStructureKind = { + type: 'workspaceContext', + kind: 'menuStructure', + name: 'Element Menu Structure Workspace Context', + alias: 'Umb.Context.Element.Menu.Structure', + api: () => import('./element-menu-structure.context.js'), + meta: { + menuItemAlias: UMB_ELEMENT_MENU_ITEM_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_WORKSPACE_ALIAS, + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], +}; + +const workspaceActions: Array = [ + { + type: 'workspaceAction', + kind: 'default', + alias: 'Umb.WorkspaceAction.Element.Save', + name: 'Save Element Workspace Action', + api: UmbSubmitWorkspaceAction, + meta: { + label: '#buttons_save', + look: 'secondary', + color: 'positive', + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_WORKSPACE_ALIAS, + }, + { + alias: UMB_ELEMENT_USER_PERMISSION_CONDITION_ALIAS, + allOf: [UMB_USER_PERMISSION_ELEMENT_UPDATE], + }, + { + alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, + }, + ], + }, +]; + +const workspaceViews: Array = [ + { + type: 'workspaceView', + kind: 'contentEditor', + alias: 'Umb.WorkspaceView.Element.Edit', + name: 'Element Workspace Edit View', + weight: 200, + meta: { + label: '#general_content', + pathname: 'content', + icon: 'document', + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_WORKSPACE_ALIAS, + }, + ], + }, + { + type: 'workspaceView', + alias: 'Umb.WorkspaceView.Element.Info', + name: 'Element Workspace Info View', + element: () => import('./views/info/element-workspace-view-info.element.js'), + weight: 100, + meta: { + label: '#general_info', + pathname: 'info', + icon: 'info', + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_WORKSPACE_ALIAS, + }, + ], + }, +]; + +const workspaceFooterApp: ManifestWorkspaceFooterApp = { + type: 'workspaceFooterApp', + kind: 'variantMenuBreadcrumb', + alias: 'Umb.WorkspaceFooterApp.Element.Breadcrumb', + name: 'Element Breadcrumb Workspace Footer App', + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_ELEMENT_WORKSPACE_ALIAS, + }, + ], +}; + +export const manifests: Array = [ + ...elementRoot, + menuStructure, + workspace, + ...workspaceActions, + ...workspaceViews, + workspaceFooterApp, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/property-dataset-context/element-workspace-property-dataset-context.token.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/property-dataset-context/element-workspace-property-dataset-context.token.ts new file mode 100644 index 000000000000..a801021f82c0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/property-dataset-context/element-workspace-property-dataset-context.token.ts @@ -0,0 +1,14 @@ +import { UMB_ELEMENT_ENTITY_TYPE } from '../../entity.js'; +import type { UmbElementWorkspacePropertyDatasetContext } from './element-workspace-property-dataset-context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; +import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property'; +import type { UmbPropertyDatasetContext } from '@umbraco-cms/backoffice/property'; + +const IsElementPropertyDatasetContext = ( + context: UmbPropertyDatasetContext, +): context is UmbElementWorkspacePropertyDatasetContext => context.getEntityType() === UMB_ELEMENT_ENTITY_TYPE; + +export const UMB_ELEMENT_WORKSPACE_PROPERTY_DATASET_CONTEXT = new UmbContextToken< + UmbPropertyDatasetContext, + UmbElementWorkspacePropertyDatasetContext +>(UMB_PROPERTY_DATASET_CONTEXT.toString(), undefined, IsElementPropertyDatasetContext); diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/property-dataset-context/element-workspace-property-dataset-context.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/property-dataset-context/element-workspace-property-dataset-context.ts new file mode 100644 index 000000000000..b095cfa40ec3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/property-dataset-context/element-workspace-property-dataset-context.ts @@ -0,0 +1,9 @@ +import type { UmbElementDetailModel, UmbElementVariantModel } from '../../types.js'; +import { UmbContentPropertyDatasetContext } from '@umbraco-cms/backoffice/content'; +import type { UmbDocumentTypeDetailModel } from '@umbraco-cms/backoffice/document-type'; + +export class UmbElementWorkspacePropertyDatasetContext extends UmbContentPropertyDatasetContext< + UmbElementDetailModel, + UmbDocumentTypeDetailModel, + UmbElementVariantModel +> {} diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/types.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/types.ts new file mode 100644 index 000000000000..006d608e26ca --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/types.ts @@ -0,0 +1 @@ +export type * from './element-workspace.context.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/views/info/element-workspace-view-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/views/info/element-workspace-view-info.element.ts new file mode 100644 index 000000000000..ac0ef488c9ee --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/elements/workspace/views/info/element-workspace-view-info.element.ts @@ -0,0 +1,246 @@ +import { UMB_ELEMENT_WORKSPACE_CONTEXT } from '../../constants.js'; +import { UMB_ELEMENT_WORKSPACE_PROPERTY_DATASET_CONTEXT } from '../../property-dataset-context/element-workspace-property-dataset-context.token.js'; +import type { UmbElementVariantModel } from '../../../types.js'; +import { UmbElementVariantState } from '../../../types.js'; +import { css, customElement, html, ifDefined, nothing, 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'; +import type { UmbDocumentTypeDetailModel } from '@umbraco-cms/backoffice/document-type'; +import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; +import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry'; +import { UMB_SECTION_USER_PERMISSION_CONDITION_ALIAS } from '@umbraco-cms/backoffice/section'; +import { UMB_SETTINGS_SECTION_ALIAS } from '@umbraco-cms/backoffice/settings'; + +const TimeOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', +}; + +@customElement('umb-element-workspace-view-info') +export class UmbElementWorkspaceViewInfoElement extends UmbLitElement { + @state() + private _elementUnique = ''; + + // Element Type (Document Type) + @state() + private _elementTypeUnique?: string = ''; + + @state() + private _elementTypeName?: string; + + @state() + private _elementTypeIcon?: string; + + @state() + private _variant?: UmbElementVariantModel; + + @state() + private _hasSettingsAccess: boolean = false; + + #workspaceContext?: typeof UMB_ELEMENT_WORKSPACE_CONTEXT.TYPE; + + @state() + private _routeBuilder?: UmbModalRouteBuilder; + + constructor() { + super(); + + new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) + .addAdditionalPath('general/:entityType') + .onSetup((params) => { + return { data: { entityType: params.entityType, preset: {} } }; + }) + .observeRouteBuilder((routeBuilder) => { + this._routeBuilder = routeBuilder; + }); + + this.consumeContext(UMB_ELEMENT_WORKSPACE_CONTEXT, (context) => { + this.#workspaceContext = context; + this._elementTypeUnique = this.#workspaceContext?.getContentTypeUnique(); + this.#observeContent(); + }); + + this.consumeContext(UMB_ELEMENT_WORKSPACE_PROPERTY_DATASET_CONTEXT, (context) => { + this.observe(context?.currentVariant, (currentVariant) => { + this._variant = currentVariant as UmbElementVariantModel | undefined; + }); + }); + + createExtensionApiByAlias(this, UMB_SECTION_USER_PERMISSION_CONDITION_ALIAS, [ + { + config: { + match: UMB_SETTINGS_SECTION_ALIAS, + }, + onChange: (permitted: boolean) => { + this._hasSettingsAccess = permitted; + }, + }, + ]); + } + + #observeContent() { + if (!this.#workspaceContext) return; + + this.observe( + this.#workspaceContext.structure.ownerContentType, + (elementType) => { + this._elementTypeName = (elementType as UmbDocumentTypeDetailModel | undefined)?.name; + this._elementTypeIcon = (elementType as UmbDocumentTypeDetailModel | undefined)?.icon; + }, + '_elementType', + ); + + this.observe( + this.#workspaceContext.unique, + (unique) => { + this._elementUnique = unique!; + }, + '_elementUnique', + ); + } + + #renderStateTag() { + switch (this._variant?.state) { + case UmbElementVariantState.DRAFT: + return html` + + ${this.localize.term('content_unpublished')} + + `; + case UmbElementVariantState.PUBLISHED: + case UmbElementVariantState.PUBLISHED_PENDING_CHANGES: + return html` + + ${this.localize.term('content_published')} + + `; + case UmbElementVariantState.TRASHED: + return html` + + ${this.localize.term('content_trashed')} + + `; + default: + return html` + + ${this.localize.term('content_notCreated')} + + `; + } + } + + override render() { + return html` +
+ +
+
+ + ${this.#renderGeneralSection()} + +
+ `; + } + + #renderGeneralSection() { + const editDocumentTypePath = this._routeBuilder?.({ entityType: 'document-type' }) ?? ''; + + return html` +
${this.#renderStateTag()}
+ ${this.#renderCreateDate()} ${this.#renderUpdateDate()} ${this.#renderPublishDate()} + +
+ Document Type + + + +
+
+ Id + ${this._elementUnique} +
+ `; + } + + #renderCreateDate() { + if (!this._variant?.createDate) return nothing; + return this.#renderDate(this._variant.createDate, 'content_createDate', 'Created'); + } + + #renderUpdateDate() { + if (!this._variant?.updateDate) return nothing; + return this.#renderDate(this._variant.updateDate, 'content_updateDate', 'Last edited'); + } + + #renderPublishDate() { + if (!this._variant?.publishDate) return nothing; + return this.#renderDate(this._variant.publishDate, 'content_lastPublished', 'Last published'); + } + + #renderDate(date: string, labelKey: string, labelText: string) { + return html` +
+ ${labelText} + + + +
+ `; + } + + static override styles = [ + css` + :host { + display: grid; + gap: var(--uui-size-layout-1); + padding: var(--uui-size-layout-1); + grid-template-columns: 1fr 350px; + } + + div.container { + display: flex; + flex-direction: column; + gap: var(--uui-size-layout-1); + } + + #general-section { + display: flex; + flex-direction: column; + } + + .general-item { + display: flex; + flex-direction: column; + gap: var(--uui-size-space-1); + } + + .general-item:not(:last-child) { + margin-bottom: var(--uui-size-space-6); + } + + uui-ref-node-document-type[readonly] { + padding-top: 7px; + padding-bottom: 7px; + } + `, + ]; +} + +export default UmbElementWorkspaceViewInfoElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-element-workspace-view-info': UmbElementWorkspaceViewInfoElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/index.ts b/src/Umbraco.Web.UI.Client/src/packages/library/index.ts new file mode 100644 index 000000000000..da850d85bb7c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/index.ts @@ -0,0 +1,2 @@ +export * from './menu/index.js'; +export * from './section/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/library/manifests.ts new file mode 100644 index 000000000000..53ba84b18343 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/manifests.ts @@ -0,0 +1,4 @@ +import { manifests as menuManifests } from './menu/manifests.js'; +import { manifests as sectionManifests } from './section/manifests.js'; + +export const manifests: Array = [...menuManifests, ...sectionManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/menu/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/library/menu/constants.ts new file mode 100644 index 000000000000..cc579613e4e6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/menu/constants.ts @@ -0,0 +1 @@ +export const UMB_LIBRARY_MENU_ALIAS = 'Umb.Menu.Library'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/menu/index.ts b/src/Umbraco.Web.UI.Client/src/packages/library/menu/index.ts new file mode 100644 index 000000000000..4f07201dcf0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/menu/index.ts @@ -0,0 +1 @@ +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/menu/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/library/menu/manifests.ts new file mode 100644 index 000000000000..6381415d4eb9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/menu/manifests.ts @@ -0,0 +1,30 @@ +import { UMB_LIBRARY_SECTION_ALIAS } from '../section/index.js'; +import { UMB_LIBRARY_MENU_ALIAS } from './constants.js'; +import { UMB_SECTION_ALIAS_CONDITION_ALIAS } from '@umbraco-cms/backoffice/section'; +import type { ManifestMenu, ManifestSectionSidebarAppMenuKind } from '@umbraco-cms/backoffice/menu'; + +const menu: ManifestMenu = { + type: 'menu', + alias: UMB_LIBRARY_MENU_ALIAS, + name: 'Library Menu', +}; + +const sectionSidebarApp: ManifestSectionSidebarAppMenuKind = { + type: 'sectionSidebarApp', + kind: 'menu', + alias: 'Umb.SidebarMenu.Library', + name: 'Library Sidebar Menu', + weight: 100, + meta: { + label: '#sections_library', + menu: UMB_LIBRARY_MENU_ALIAS, + }, + conditions: [ + { + alias: UMB_SECTION_ALIAS_CONDITION_ALIAS, + match: UMB_LIBRARY_SECTION_ALIAS, + }, + ], +}; + +export const manifests: Array = [menu, sectionSidebarApp]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/package.json b/src/Umbraco.Web.UI.Client/src/packages/library/package.json new file mode 100644 index 000000000000..cf8d2abb3ab2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/library", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/section/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/library/section/constants.ts new file mode 100644 index 000000000000..b8142cb163a0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/section/constants.ts @@ -0,0 +1,9 @@ +import { UMB_SECTION_PATH_PATTERN } from '@umbraco-cms/backoffice/section'; + +export const UMB_LIBRARY_SECTION_ALIAS = 'Umb.Section.Library'; + +export const UMB_LIBRARY_SECTION_PATHNAME = 'library'; + +export const UMB_LIBRARY_SECTION_PATH = UMB_SECTION_PATH_PATTERN.generateAbsolute({ + sectionName: UMB_LIBRARY_SECTION_PATHNAME, +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/section/index.ts b/src/Umbraco.Web.UI.Client/src/packages/library/section/index.ts new file mode 100644 index 000000000000..4f07201dcf0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/section/index.ts @@ -0,0 +1 @@ +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/section/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/library/section/manifests.ts new file mode 100644 index 000000000000..add43c234d0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/section/manifests.ts @@ -0,0 +1,21 @@ +import { UMB_LIBRARY_SECTION_ALIAS, UMB_LIBRARY_SECTION_PATHNAME } from './constants.js'; +import { UMB_SECTION_USER_PERMISSION_CONDITION_ALIAS } from '@umbraco-cms/backoffice/section'; + +export const manifests: Array = [ + { + type: 'section', + alias: UMB_LIBRARY_SECTION_ALIAS, + name: 'Library Section', + weight: 850, + meta: { + label: '#sections_library', + pathname: UMB_LIBRARY_SECTION_PATHNAME, + }, + conditions: [ + { + alias: UMB_SECTION_USER_PERMISSION_CONDITION_ALIAS, + match: UMB_LIBRARY_SECTION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/umbraco-package.ts b/src/Umbraco.Web.UI.Client/src/packages/library/umbraco-package.ts new file mode 100644 index 000000000000..6e0b7ba92433 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/umbraco-package.ts @@ -0,0 +1,9 @@ +export const name = 'Umbraco.Core.Library'; +export const extensions = [ + { + name: 'Umbraco Library Bundle', + alias: 'Umb.Bundle.Library', + type: 'bundle', + js: () => import('./manifests.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/library/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/library/vite.config.ts new file mode 100644 index 000000000000..f57ff5edb1c9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/library/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/library'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ dist }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/index.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/index.ts new file mode 100644 index 000000000000..45cdf66b9219 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/entity-data-picker/index.ts @@ -0,0 +1 @@ +export * from './input/input-entity-data.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts index d628cd8325e1..304c1298b78a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'umbraco-package': 'umbraco-package.ts', manifests: 'manifests.ts', 'content-picker/index': './content-picker/index.ts', + 'entity-data-picker/index': './entity-data-picker/index.ts', }, }), }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/constants.ts index 6d7855c1514e..54f258705dcd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/constants.ts @@ -1,6 +1,3 @@ export { UMB_RELATION_ENTITY_TYPE } from './entity.js'; export * from './collection/constants.js'; -export * from './entity-actions/bulk-delete/constants.js'; -export * from './entity-actions/bulk-trash/constants.js'; -export * from './entity-actions/delete/constants.js'; -export * from './entity-actions/trash/constants.js'; +export * from './entity-actions/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/bulk-delete/index.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/bulk-delete/index.ts index 64a7503c0f05..9961d2e1555d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/bulk-delete/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/bulk-delete/index.ts @@ -1,2 +1 @@ -export * from './bulk-delete-with-relation.action.js'; -export type * from './types.js'; +export { UmbBulkDeleteWithRelationEntityAction } from './bulk-delete-with-relation.action.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/bulk-trash/index.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/bulk-trash/index.ts index 5f67a28b7978..b858c3dd3c2d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/bulk-trash/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/bulk-trash/index.ts @@ -1,2 +1 @@ -export * from './bulk-trash-with-relation.action.js'; -export type * from './types.js'; +export { UmbBulkTrashWithRelationEntityAction } from './bulk-trash-with-relation.action.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/constants.ts new file mode 100644 index 000000000000..9502f16b5f7d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/constants.ts @@ -0,0 +1,4 @@ +export * from './bulk-delete/constants.js'; +export * from './bulk-trash/constants.js'; +export * from './delete/constants.js'; +export * from './trash/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/delete/index.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/delete/index.ts index a609dad289f9..562af160d243 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/delete/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/delete/index.ts @@ -1,2 +1 @@ -export * from './delete-with-relation.action.js'; -export type * from './types.js'; +export { UmbDeleteWithRelationEntityAction } from './delete-with-relation.action.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/index.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/index.ts new file mode 100644 index 000000000000..314a77aeebad --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/index.ts @@ -0,0 +1,4 @@ +export * from './bulk-delete/index.js'; +export * from './bulk-trash/index.js'; +export * from './delete/index.js'; +export * from './trash/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/trash/index.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/trash/index.ts index cca1fbe202bf..42c9e68c151c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/trash/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/trash/index.ts @@ -1,2 +1 @@ -export * from './trash-with-relation.action.js'; -export type * from './types.js'; +export { UmbTrashWithRelationEntityAction } from './trash-with-relation.action.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/types.ts new file mode 100644 index 000000000000..2654b1992ba0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/entity-actions/types.ts @@ -0,0 +1,4 @@ +export type * from './bulk-delete/types.js'; +export type * from './bulk-trash/types.js'; +export type * from './delete/types.js'; +export type * from './trash/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/index.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/index.ts index 6bea1f6141b2..b822e5e0c554 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/index.ts @@ -1,4 +1,5 @@ export * from './collection/index.js'; +export * from './entity-actions/index.js'; export * from './constants.js'; export * from './entity.js'; export * from './global-components/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/types.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/types.ts index 3306f451e178..262e6792015f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/types.ts @@ -1,4 +1,5 @@ import type { UmbRelationEntityType } from './entity.js'; +export type * from './entity-actions/types.js'; export type * from './global-components/types.js'; export type * from './reference/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts index d160525d8d49..c3df916459a1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts @@ -15,11 +15,13 @@ export class UmbCurrentUserContext extends UmbContextBase { readonly allowedSections = this.#currentUser.asObservablePart((user) => user?.allowedSections); readonly avatarUrls = this.#currentUser.asObservablePart((user) => user?.avatarUrls); readonly documentStartNodeUniques = this.#currentUser.asObservablePart((user) => user?.documentStartNodeUniques); + readonly elementStartNodeUniques = this.#currentUser.asObservablePart((user) => user?.elementStartNodeUniques); readonly email = this.#currentUser.asObservablePart((user) => user?.email); readonly fallbackPermissions = this.#currentUser.asObservablePart((user) => user?.fallbackPermissions); readonly hasAccessToAllLanguages = this.#currentUser.asObservablePart((user) => user?.hasAccessToAllLanguages); readonly hasAccessToSensitiveData = this.#currentUser.asObservablePart((user) => user?.hasAccessToSensitiveData); readonly hasDocumentRootAccess = this.#currentUser.asObservablePart((user) => user?.hasDocumentRootAccess); + readonly hasElementRootAccess = this.#currentUser.asObservablePart((user) => user?.hasElementRootAccess); readonly hasMediaRootAccess = this.#currentUser.asObservablePart((user) => user?.hasMediaRootAccess); readonly isAdmin = this.#currentUser.asObservablePart((user) => user?.isAdmin); readonly languageIsoCode = this.#currentUser.asObservablePart((user) => user?.languageIsoCode); diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts index 1058924a347b..d5367421abbb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts @@ -38,25 +38,19 @@ export class UmbCurrentUserServerDataSource extends UmbControllerBase { const user: UmbCurrentUserModel = { allowedSections: data.allowedSections, avatarUrls: data.avatarUrls, - documentStartNodeUniques: data.documentStartNodeIds.map((node) => { - return { - unique: node.id, - }; - }), + documentStartNodeUniques: data.documentStartNodeIds.map((node) => ({ unique: node.id })), + elementStartNodeUniques: data.elementStartNodeIds.map((node) => ({ unique: node.id })), email: data.email, fallbackPermissions: data.fallbackPermissions, hasAccessToAllLanguages: data.hasAccessToAllLanguages, hasAccessToSensitiveData: data.hasAccessToSensitiveData, hasDocumentRootAccess: data.hasDocumentRootAccess, hasMediaRootAccess: data.hasMediaRootAccess, + hasElementRootAccess: data.hasElementRootAccess, isAdmin: data.isAdmin, languageIsoCode: data.languageIsoCode || 'en-us', // TODO: make global variable languages: data.languages, - mediaStartNodeUniques: data.mediaStartNodeIds.map((node) => { - return { - unique: node.id, - }; - }), + mediaStartNodeUniques: data.mediaStartNodeIds.map((node) => ({ unique: node.id })), name: data.name, permissions, unique: data.id, diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts index 440f0882a7fb..65e442a05477 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts @@ -13,11 +13,13 @@ export interface UmbCurrentUserModel { allowedSections: Array; avatarUrls: Array; documentStartNodeUniques: Array; + elementStartNodeUniques: Array; email: string; fallbackPermissions: Array; hasAccessToAllLanguages: boolean; hasAccessToSensitiveData: boolean; hasDocumentRootAccess: boolean; + hasElementRootAccess: boolean; hasMediaRootAccess: boolean; isAdmin: boolean; languageIsoCode: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/collection/repository/user-group-collection.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/collection/repository/user-group-collection.server.data-source.ts index ed7a0e63a688..e3693ef499e6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/collection/repository/user-group-collection.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/collection/repository/user-group-collection.server.data-source.ts @@ -38,6 +38,8 @@ export class UmbUserGroupCollectionServerDataSource implements UmbCollectionData aliasCanBeChanged: item.aliasCanBeChanged, documentRootAccess: item.documentRootAccess, documentStartNode: item.documentStartNode ? { unique: item.documentStartNode.id } : null, + elementRootAccess: item.elementRootAccess, + elementStartNode: item.elementStartNode ? { unique: item.elementStartNode.id } : null, entityType: UMB_USER_GROUP_ENTITY_TYPE, fallbackPermissions: item.fallbackPermissions, hasAccessToAllLanguages: item.hasAccessToAllLanguages, diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/repository/detail/user-group-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/repository/detail/user-group-detail.server.data-source.ts index 76b1a20b7f8e..9506a730f9ba 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/repository/detail/user-group-detail.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/repository/detail/user-group-detail.server.data-source.ts @@ -33,6 +33,8 @@ export class UmbUserGroupServerDataSource aliasCanBeChanged: true, documentRootAccess: false, documentStartNode: null, + elementRootAccess: false, + elementStartNode: null, entityType: UMB_USER_GROUP_ENTITY_TYPE, fallbackPermissions: [], hasAccessToAllLanguages: false, @@ -86,6 +88,8 @@ export class UmbUserGroupServerDataSource alias: data.alias, documentRootAccess: data.documentRootAccess, documentStartNode: data.documentStartNode ? { unique: data.documentStartNode.id } : null, + elementRootAccess: data.elementRootAccess, + elementStartNode: data.elementStartNode ? { unique: data.elementStartNode.id } : null, entityType: UMB_USER_GROUP_ENTITY_TYPE, fallbackPermissions: data.fallbackPermissions, hasAccessToAllLanguages: data.hasAccessToAllLanguages, @@ -129,6 +133,8 @@ export class UmbUserGroupServerDataSource alias: model.alias, documentRootAccess: model.documentRootAccess, documentStartNode: model.documentStartNode ? { id: model.documentStartNode.unique } : null, + elementRootAccess: model.elementRootAccess, + elementStartNode: model.elementStartNode ? { id: model.elementStartNode.unique } : null, fallbackPermissions: model.fallbackPermissions, hasAccessToAllLanguages: model.hasAccessToAllLanguages, icon: model.icon, @@ -180,6 +186,8 @@ export class UmbUserGroupServerDataSource alias: model.alias, documentRootAccess: model.documentRootAccess, documentStartNode: model.documentStartNode ? { id: model.documentStartNode.unique } : null, + elementRootAccess: model.elementRootAccess, + elementStartNode: model.elementStartNode ? { id: model.elementStartNode.unique } : null, fallbackPermissions: model.fallbackPermissions, hasAccessToAllLanguages: model.hasAccessToAllLanguages, icon: model.icon, diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/types.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/types.ts index 6f27c2f369b7..c4669bbe524a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/types.ts @@ -6,6 +6,8 @@ export interface UmbUserGroupDetailModel { aliasCanBeChanged: boolean; documentRootAccess: boolean; documentStartNode: { unique: string } | null; + elementRootAccess: boolean; + elementStartNode: { unique: string } | null; entityType: UmbUserGroupEntityType; fallbackPermissions: Array; hasAccessToAllLanguages: boolean; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permission-groups.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permission-groups.element.ts index 2d2d09dff9a0..b74fca3ceac6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permission-groups.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permission-groups.element.ts @@ -52,8 +52,8 @@ export class UmbUserGroupEntityTypePermissionGroupsElement extends UmbLitElement return html`${repeat( this._groups, (group) => group.entityType, - (group) => - html` + (group) => html` +
${group.headline}
-
`, +
+ `, )} ${this.#renderUngroupedGranularPermissions()}`; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permissions.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permissions.element.ts index 0edd3e9c33aa..92ec2dc7eb6b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permissions.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permissions.element.ts @@ -38,14 +38,13 @@ export class UmbUserGroupEntityTypePermissionsElement extends UmbLitElement { } override render() { - return this.entityType - ? html` - - ` - : nothing; + if (!this.entityType) return nothing; + return html` + + `; } static override styles = [UmbTextStyles]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace.context.ts index 52303de88ef2..12ae3b44691b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace.context.ts @@ -25,6 +25,8 @@ export class UmbUserGroupWorkspaceContext ); readonly documentStartNode = this._data.createObservablePartOfCurrent((data) => data?.documentStartNode || null); readonly documentRootAccess = this._data.createObservablePartOfCurrent((data) => data?.documentRootAccess || false); + readonly elementStartNode = this._data.createObservablePartOfCurrent((data) => data?.elementStartNode || null); + readonly elementRootAccess = this._data.createObservablePartOfCurrent((data) => data?.elementRootAccess || false); readonly mediaStartNode = this._data.createObservablePartOfCurrent((data) => data?.mediaStartNode || null); readonly mediaRootAccess = this._data.createObservablePartOfCurrent((data) => data?.mediaRootAccess || false); readonly fallbackPermissions = this._data.createObservablePartOfCurrent((data) => data?.fallbackPermissions || []); diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/views/user-group-details-workspace-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/views/user-group-details-workspace-view.element.ts index 1aaa43beb671..c37e11907063 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/views/user-group-details-workspace-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/views/user-group-details-workspace-view.element.ts @@ -1,15 +1,17 @@ import type { UmbUserGroupDetailModel } from '../../../types.js'; import { UMB_USER_GROUP_WORKSPACE_CONTEXT } from '../user-group-workspace.context-token.js'; -import type { UmbInputSectionElement } from '@umbraco-cms/backoffice/section'; +import { css, customElement, html, nothing, 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 { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import type { UmbInputEntityDataElement } from '@umbraco-cms/backoffice/entity-data-picker'; import type { UmbInputLanguageElement } from '@umbraco-cms/backoffice/language'; -import { css, html, nothing, customElement, state } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbInputSectionElement } from '@umbraco-cms/backoffice/section'; import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UUIBooleanInputEvent } from '@umbraco-cms/backoffice/external/uui'; import '../components/user-group-entity-type-permission-groups.element.js'; +import '@umbraco-cms/backoffice/entity-data-picker'; @customElement('umb-user-group-details-workspace-view') export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement implements UmbWorkspaceViewElement { @@ -31,6 +33,12 @@ export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement imple @state() private _documentRootAccess: UmbUserGroupDetailModel['documentRootAccess'] = false; + @state() + private _elementStartNode?: UmbUserGroupDetailModel['elementStartNode']; + + @state() + private _elementRootAccess: UmbUserGroupDetailModel['elementRootAccess'] = false; + @state() private _mediaStartNode?: UmbUserGroupDetailModel['mediaStartNode']; @@ -70,6 +78,18 @@ export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement imple '_observeDocumentStartNode', ); + this.observe( + this.#workspaceContext?.elementRootAccess, + (value) => (this._elementRootAccess = value ?? false), + '_observeElementRootAccess', + ); + + this.observe( + this.#workspaceContext?.elementStartNode, + (value) => (this._elementStartNode = value), + '_observeElementStartNode', + ); + this.observe( this.#workspaceContext?.mediaRootAccess, (value) => (this._mediaRootAccess = value ?? false), @@ -122,6 +142,23 @@ export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement imple this.#workspaceContext?.updateProperty('documentStartNode', selected ? { unique: selected } : null); } + #onAllowAllElementsChange(event: UUIBooleanInputEvent) { + event.stopPropagation(); + const target = event.target; + // TODO make contexts method + this.#workspaceContext?.updateProperty('elementRootAccess', target.checked); + this.#workspaceContext?.updateProperty('elementStartNode', null); + } + + #onElementStartNodeChange(event: CustomEvent & { target: UmbInputEntityDataElement }) { + event.stopPropagation(); + // TODO: get back to this when elements have been decoupled from users. + const target = event.target; + const selected = target.selection?.[0]; + // TODO make contexts method + this.#workspaceContext?.updateProperty('elementStartNode', selected ? { unique: selected } : null); + } + #onAllowAllMediaChange(event: UUIBooleanInputEvent) { event.stopPropagation(); const target = event.target; @@ -159,6 +196,7 @@ export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement imple ${this.#renderLanguageAccess()} ${this.#renderDocumentAccess()} ${this.#renderMediaAccess()} + ${this.#renderElementAccess()} ${this.#renderPermissionGroups()} @@ -175,17 +213,18 @@ export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement imple
- ${this._hasAccessToAllLanguages === false - ? html` - - ` - : nothing} + ${when( + this._hasAccessToAllLanguages === false, + () => html` + + `, + )}
`; @@ -199,20 +238,50 @@ export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement imple
+ ${when( + this._documentRootAccess === false, + () => html` + + + `, + )} + + `; + } - ${this._documentRootAccess === false - ? html` - - ` - : nothing} + #renderElementAccess() { + return html` + +
+ +
+ ${when( + this._elementRootAccess === false, + () => html` + + + `, + )}
`; } @@ -225,26 +294,27 @@ export class UmbUserGroupDetailsWorkspaceViewElement extends UmbLitElement imple
- - ${this._mediaRootAccess === false - ? html` - - ` - : nothing} + ${when( + this._mediaRootAccess === false, + () => html` + + + `, + )} `; } #renderPermissionGroups() { - return html` `; + return html``; } static override styles = [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/repository/user-collection.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/repository/user-collection.server.data-source.ts index ceff7f2a9077..b40ce8ead198 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/repository/user-collection.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/repository/user-collection.server.data-source.ts @@ -67,18 +67,12 @@ export class UmbUserCollectionServerDataSource implements UmbCollectionDataSourc }), unique: item.id, languageIsoCode: item.languageIsoCode || null, - documentStartNodeUniques: item.documentStartNodeIds.map((node) => { - return { - unique: node.id, - }; - }), - mediaStartNodeUniques: item.mediaStartNodeIds.map((node) => { - return { - unique: node.id, - }; - }), + documentStartNodeUniques: item.documentStartNodeIds.map((node) => ({ unique: node.id })), + mediaStartNodeUniques: item.mediaStartNodeIds.map((node) => ({ unique: node.id })), + elementStartNodeUniques: item.elementStartNodeIds.map((node) => ({ unique: node.id })), hasDocumentRootAccess: item.hasDocumentRootAccess, hasMediaRootAccess: item.hasMediaRootAccess, + hasElementRootAccess: item.hasElementRootAccess, avatarUrls: item.avatarUrls, state: item.state, failedLoginAttempts: item.failedLoginAttempts, diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/components/index.ts index a3e23b4c8791..0e08da9751c0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/components/index.ts @@ -1,5 +1,6 @@ import './user-avatar/user-avatar.element.js'; import './user-document-start-node/user-document-start-node.element.js'; +import './user-element-start-node/user-element-start-node.element.js'; import './user-input/user-input.element.js'; import './user-media-start-node/user-media-start-node.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/components/user-element-start-node/user-element-start-node.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/components/user-element-start-node/user-element-start-node.element.ts new file mode 100644 index 000000000000..1936c799bd40 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/components/user-element-start-node/user-element-start-node.element.ts @@ -0,0 +1,77 @@ +import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry'; +import { customElement, html, property, repeat, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbItemRepository } from '@umbraco-cms/backoffice/repository'; + +@customElement('umb-user-element-start-node') +export class UmbUserElementStartNodeElement extends UmbLitElement { + #uniques: Array = []; + @property({ type: Array, attribute: false }) + public get uniques(): Array { + return this.#uniques; + } + public set uniques(value: Array) { + this.#uniques = value; + + if (this.#uniques.length > 0) { + this.#observeItems(); + } + } + + @property({ type: Boolean }) + readonly = false; + + @state() + private _displayValue: Array = []; + + async #observeItems() { + // TODO: get back to this when elements have been decoupled from users. + // The repository alias is hardcoded on purpose to avoid a element import in the user module. + const itemRepository = await createExtensionApiByAlias>( + this, + 'Umb.Repository.ElementFolderItem', + ); + const { asObservable } = await itemRepository.requestItems(this.#uniques); + + this.observe(asObservable?.(), (data) => { + this._displayValue = data || []; + }); + } + + override render() { + if (this.uniques.length < 1) { + return html` + + + + `; + } + + return repeat( + this._displayValue, + (item) => item.unique, + (item) => { + return html` + + + + + `; + }, + ); + } +} + +export default UmbUserElementStartNodeElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-user-element-start-node': UmbUserElementStartNodeElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/detail/user-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/detail/user-detail.server.data-source.ts index 2468080f304f..59a0935f4661 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/detail/user-detail.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/detail/user-detail.server.data-source.ts @@ -40,11 +40,13 @@ export class UmbUserServerDataSource implements UmbDetailDataSource { - return { - unique: node.id, - }; - }), + documentStartNodeUniques: data.documentStartNodeIds.map((node) => ({ unique: node.id })), + hasElementRootAccess: data.hasElementRootAccess, + elementStartNodeUniques: data.elementStartNodeIds.map((node) => ({ unique: node.id })), email: data.email, entityType: UMB_USER_ENTITY_TYPE, failedLoginAttempts: data.failedLoginAttempts, @@ -98,20 +98,12 @@ export class UmbUserServerDataSource implements UmbDetailDataSource { - return { - unique: node.id, - }; - }), + mediaStartNodeUniques: data.mediaStartNodeIds.map((node) => ({ unique: node.id })), name: data.name, state: data.state, unique: data.id, updateDate: data.updateDate, - userGroupUniques: data.userGroupIds.map((reference) => { - return { - unique: reference.id, - }; - }), + userGroupUniques: data.userGroupIds.map((reference) => ({ unique: reference.id })), userName: data.userName, }; @@ -166,26 +158,16 @@ export class UmbUserServerDataSource implements UmbDetailDataSource { - return { - id: node.unique, - }; - }), + documentStartNodeIds: model.documentStartNodeUniques.map((node) => ({ id: node.unique })), + elementStartNodeIds: model.elementStartNodeUniques.map((node) => ({ id: node.unique })), email: model.email, hasDocumentRootAccess: model.hasDocumentRootAccess, hasMediaRootAccess: model.hasMediaRootAccess, + hasElementRootAccess: model.hasElementRootAccess, languageIsoCode: model.languageIsoCode || '', - mediaStartNodeIds: model.mediaStartNodeUniques.map((node) => { - return { - id: node.unique, - }; - }), + mediaStartNodeIds: model.mediaStartNodeUniques.map((node) => ({ id: node.unique })), name: model.name, - userGroupIds: model.userGroupUniques.map((reference) => { - return { - id: reference.unique, - }; - }), + userGroupIds: model.userGroupUniques.map((reference) => ({ id: reference.unique })), userName: model.userName, }; @@ -240,17 +222,11 @@ export class UmbUserServerDataSource implements UmbDetailDataSource { - return { - unique: node.id, - }; - }), + documentStartNodeUniques: data.documentStartNodeIds.map((node) => ({ unique: node.id })), hasMediaRootAccess: data.hasMediaRootAccess, - mediaStartNodeUniques: data.mediaStartNodeIds.map((node) => { - return { - unique: node.id, - }; - }), + mediaStartNodeUniques: data.mediaStartNodeIds.map((node) => ({ unique: node.id })), + hasElementRootAccess: data.hasElementRootAccess, + elementStartNodeUniques: data.elementStartNodeIds.map((node) => ({ unique: node.id })), }; return { data: calculatedStartNodes }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/types.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/types.ts index 8ac522013cc2..2f37ce517b7e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/types.ts @@ -35,8 +35,10 @@ export interface UmbUserDetailModel extends UmbUserStartNodesModel { export interface UmbUserStartNodesModel { documentStartNodeUniques: Array; + elementStartNodeUniques: Array; hasDocumentRootAccess: boolean; hasMediaRootAccess: boolean; + hasElementRootAccess: boolean; mediaStartNodeUniques: Array; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/components/user-workspace-access/user-workspace-access.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/components/user-workspace-access/user-workspace-access.element.ts index ab217cc75738..7efc1d390a73 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/components/user-workspace-access/user-workspace-access.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/components/user-workspace-access/user-workspace-access.element.ts @@ -28,33 +28,51 @@ export class UmbUserWorkspaceAccessElement extends UmbLitElement { } override render() { - return html` -
- Based on the assigned groups and start nodes, the user has access to the following nodes -
- - ${this.#renderDocumentStartNodes()} -
- ${this.#renderMediaStartNodes()} -
`; + return html` + +
+ Based on the assigned groups and start nodes, the user has access to the following nodes +
+
+ ${this.#renderDocumentStartNodes()} ${this.#renderMediaStartNodes()} ${this.#renderElementStartNodes()} +
+
+ `; } #renderDocumentStartNodes() { - return html` Content - reference.unique) || - []}>`; + const uniques = this._calculatedStartNodes?.documentStartNodeUniques.map((reference) => reference.unique) || []; + return html` + +
+ +
+
+ `; + } + + #renderElementStartNodes() { + const uniques = this._calculatedStartNodes?.elementStartNodeUniques.map((reference) => reference.unique) || []; + return html` + +
+ +
+
+ `; } #renderMediaStartNodes() { - return html` Media - reference.unique) || - []}>`; + const uniques = this._calculatedStartNodes?.mediaStartNodeUniques.map((reference) => reference.unique) || []; + return html` + +
+ +
+
+ `; } static override styles = [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/components/user-workspace-assign-access/user-workspace-assign-access.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/components/user-workspace-assign-access/user-workspace-assign-access.element.ts index 218c6026de8a..91a72e4a35d1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/components/user-workspace-assign-access/user-workspace-assign-access.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/components/user-workspace-assign-access/user-workspace-assign-access.element.ts @@ -1,11 +1,14 @@ import { UMB_USER_WORKSPACE_CONTEXT } from '../../user-workspace.context-token.js'; import type { UmbUserDetailModel } from '../../../../types.js'; -import { html, customElement, state, nothing, css } from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, when, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import type { UmbInputEntityDataElement } from '@umbraco-cms/backoffice/entity-data-picker'; +import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; import type { UmbUserGroupInputElement } from '@umbraco-cms/backoffice/user-group'; import type { UUIBooleanInputEvent } from '@umbraco-cms/backoffice/external/uui'; -import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; + +import '@umbraco-cms/backoffice/entity-data-picker'; const elementName = 'umb-user-workspace-assign-access'; @customElement(elementName) @@ -20,7 +23,13 @@ export class UmbUserWorkspaceAssignAccessElement extends UmbLitElement { private _documentRootAccess: UmbUserDetailModel['hasDocumentRootAccess'] = false; @state() - private _mediaStartNodeUniques: UmbUserDetailModel['documentStartNodeUniques'] = []; + private _elementStartNodeUniques: UmbUserDetailModel['elementStartNodeUniques'] = []; + + @state() + private _elementRootAccess: UmbUserDetailModel['hasElementRootAccess'] = false; + + @state() + private _mediaStartNodeUniques: UmbUserDetailModel['mediaStartNodeUniques'] = []; @state() private _mediaRootAccess: UmbUserDetailModel['hasMediaRootAccess'] = false; @@ -51,6 +60,18 @@ export class UmbUserWorkspaceAssignAccessElement extends UmbLitElement { '_observeDocumentStartNode', ); + this.observe( + this.#workspaceContext?.hasElementRootAccess, + (value) => (this._elementRootAccess = value ?? false), + '_observeElementRootAccess', + ); + + this.observe( + this.#workspaceContext?.elementStartNodeUniques, + (value) => (this._elementStartNodeUniques = value ?? []), + '_observeElementStartNode', + ); + this.observe( this.#workspaceContext?.hasMediaRootAccess, (value) => (this._mediaRootAccess = value ?? false), @@ -88,15 +109,33 @@ export class UmbUserWorkspaceAssignAccessElement extends UmbLitElement { // TODO: get back to this when media have been decoupled from users. // The event target is deliberately set to any to avoid an import cycle with media. const target = event.target as any; - const selection: Array = target.selection.map((unique: string) => { - return { unique }; - }); + const selection: Array = target.selection.map((unique: string) => ({ unique })); // TODO make contexts method this.#workspaceContext?.updateProperty('documentStartNodeUniques', selection); // When specific start nodes are selected, disable root access this.#workspaceContext?.updateProperty('hasDocumentRootAccess', false); } + #onAllowAllElementsChange(event: UUIBooleanInputEvent) { + event.stopPropagation(); + const target = event.target; + // TODO make contexts method + this.#workspaceContext?.updateProperty('hasElementRootAccess', target.checked); + this.#workspaceContext?.updateProperty('elementStartNodeUniques', []); + } + + #onElementStartNodeChange(event: CustomEvent & { target: UmbInputEntityDataElement }) { + event.stopPropagation(); + // TODO: get back to this when media have been decoupled from users. + // The event target is deliberately set to any to avoid an import cycle with media. + const target = event.target; + const selection: Array = target.selection.map((unique: string) => ({ unique })); + // TODO make contexts method + this.#workspaceContext?.updateProperty('elementStartNodeUniques', selection); + // When specific start nodes are selected, disable root access + this.#workspaceContext?.updateProperty('hasElementRootAccess', false); + } + #onAllowAllMediaChange(event: UUIBooleanInputEvent) { event.stopPropagation(); const target = event.target; @@ -110,9 +149,7 @@ export class UmbUserWorkspaceAssignAccessElement extends UmbLitElement { // TODO: get back to this when media have been decoupled from users. // The event target is deliberately set to any to avoid an import cycle with media. const target = event.target as any; - const selection: Array = target.selection.map((unique: string) => { - return { unique }; - }); + const selection: Array = target.selection.map((unique: string) => ({ unique })); // TODO make contexts method this.#workspaceContext?.updateProperty('mediaStartNodeUniques', selection); // When specific start nodes are selected, disable root access @@ -125,20 +162,23 @@ export class UmbUserWorkspaceAssignAccessElement extends UmbLitElement {
Assign Access
${this.#renderGroupAccess()} ${this.#renderDocumentAccess()} ${this.#renderMediaAccess()} + ${this.#renderElementAccess()}
`; } #renderGroupAccess() { - return html` - reference.unique)} - @change=${this.#onUserGroupsChange}> - `; + return html` + + reference.unique)} + @change=${this.#onUserGroupsChange}> + + `; } #renderDocumentAccess() { @@ -149,19 +189,48 @@ export class UmbUserWorkspaceAssignAccessElement extends UmbLitElement {
+ ${when( + this._documentRootAccess === false, + () => html` + reference.unique)} + @change=${this.#onDocumentStartNodeChange}> + + `, + )} + + `; + } - ${this._documentRootAccess === false - ? html` - reference.unique)} - @change=${this.#onDocumentStartNodeChange}> - ` - : nothing} + #renderElementAccess() { + return html` + +
+ +
+ ${when( + this._elementRootAccess === false, + () => html` + reference.unique)} + .dataSourceAlias=${'Umb.PropertyEditorDataSource.ElementFolder'} + .dataSourceConfig=${[]} + @change=${this.#onElementStartNodeChange}> + + `, + )}
`; } @@ -174,19 +243,20 @@ export class UmbUserWorkspaceAssignAccessElement extends UmbLitElement {
- - ${this._mediaRootAccess === false - ? html` - reference.unique)} - @change=${this.#onMediaStartNodeChange}> - ` - : nothing} + ${when( + this._mediaRootAccess === false, + () => html` + reference.unique)} + @change=${this.#onMediaStartNodeChange}> + + `, + )} `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts index 5501b2360833..9a5b5b29876d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts @@ -30,6 +30,12 @@ export class UmbUserWorkspaceContext readonly hasDocumentRootAccess = this._data.createObservablePartOfCurrent( (data) => data?.hasDocumentRootAccess || false, ); + readonly elementStartNodeUniques = this._data.createObservablePartOfCurrent( + (data) => data?.elementStartNodeUniques || [], + ); + readonly hasElementRootAccess = this._data.createObservablePartOfCurrent( + (data) => data?.hasElementRootAccess || false, + ); readonly mediaStartNodeUniques = this._data.createObservablePartOfCurrent( (data) => data?.mediaStartNodeUniques || [], ); diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index 1c64f1663534..dc4022ff82e5 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -71,11 +71,13 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/document-type": ["./src/packages/documents/document-types/index.ts"], "@umbraco-cms/backoffice/document": ["./src/packages/documents/documents/index.ts"], "@umbraco-cms/backoffice/dropzone": ["./src/packages/media/dropzone/index.ts"], + "@umbraco-cms/backoffice/element": ["./src/packages/elements/index.ts"], "@umbraco-cms/backoffice/entity-action": ["./src/packages/core/entity-action/index.ts"], "@umbraco-cms/backoffice/entity-bulk-action": ["./src/packages/core/entity-bulk-action/index.ts"], "@umbraco-cms/backoffice/entity-create-option-action": [ "./src/packages/core/entity-create-option-action/index.ts" ], + "@umbraco-cms/backoffice/entity-data-picker": ["./src/packages/property-editors/entity-data-picker/index.ts"], "@umbraco-cms/backoffice/entity-item": ["./src/packages/core/entity-item/index.ts"], "@umbraco-cms/backoffice/entity-sign": ["./src/packages/core/entity-sign/index.ts"], "@umbraco-cms/backoffice/entity-flag": ["./src/packages/core/entity-flag/index.ts"], @@ -91,6 +93,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/imaging": ["./src/packages/media/imaging/index.ts"], "@umbraco-cms/backoffice/interaction-memory": ["./src/packages/core/interaction-memory/index.ts"], "@umbraco-cms/backoffice/language": ["./src/packages/language/index.ts"], + "@umbraco-cms/backoffice/library": ["./src/packages/library/index.ts"], "@umbraco-cms/backoffice/lit-element": ["./src/packages/core/lit-element/index.ts"], "@umbraco-cms/backoffice/localization": ["./src/packages/core/localization/index.ts"], "@umbraco-cms/backoffice/log-viewer": ["./src/packages/log-viewer/index.ts"], diff --git a/src/Umbraco.Web.UI.Login/package-lock.json b/src/Umbraco.Web.UI.Login/package-lock.json index 572326dfe0d0..204566f35918 100644 --- a/src/Umbraco.Web.UI.Login/package-lock.json +++ b/src/Umbraco.Web.UI.Login/package-lock.json @@ -3776,6 +3776,215 @@ "is-wsl": "^3.1.0" }, "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/graphql": { + "version": "16.10.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/handlebars": { + "version": "4.7.8", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/lit": { + "version": "3.3.1", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.4.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "dev": true, + "license": "MIT" + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { "node": ">=18" }, "funding": { @@ -4762,4 +4971,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs index 2a00680ab73d..5e2a4355b092 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs @@ -1,8 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; -using System.Collections.Generic; using System.Globalization; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; diff --git a/tests/Umbraco.Tests.Common/Builders/ContentCultureInfosCollectionBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentCultureInfosCollectionBuilder.cs index b70437a31d4c..741d81ac8008 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentCultureInfosCollectionBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentCultureInfosCollectionBuilder.cs @@ -5,12 +5,12 @@ namespace Umbraco.Cms.Tests.Common.Builders; -public class ContentCultureInfosCollectionBuilder : ChildBuilderBase, +public class ContentCultureInfosCollectionBuilder : ChildBuilderBase, IBuildContentCultureInfosCollection { private readonly List _cultureInfosBuilders; - public ContentCultureInfosCollectionBuilder(ContentBuilder parentBuilder) : base(parentBuilder) => + public ContentCultureInfosCollectionBuilder(IBuildContentCultureInfosCollection parentBuilder) : base(parentBuilder) => _cultureInfosBuilders = new List(); public ContentCultureInfosBuilder AddCultureInfos() diff --git a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs index 633604f1ba8c..617859a0ff11 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs @@ -10,7 +10,7 @@ namespace Umbraco.Cms.Tests.Common.Builders; public class ContentTypeBuilder - : ContentTypeBaseBuilder, + : ContentTypeBaseBuilder, IWithPropertyTypeIdsIncrementingFrom, IBuildPropertyTypes { @@ -30,7 +30,7 @@ public ContentTypeBuilder() { } - public ContentTypeBuilder(ContentBuilder parentBuilder) + public ContentTypeBuilder(IBuildContentTypes parentBuilder) : base(parentBuilder) { } @@ -169,6 +169,16 @@ public static ContentType CreateBasicContentType(string alias = "basePage", stri .Build(); } + public static ContentType CreateBasicElementType(string alias = "elementType", string name = "Element Type") + { + var builder = new ContentTypeBuilder(); + return (ContentType)builder + .WithAlias(alias) + .WithName(name) + .WithIsElement(true) + .Build(); + } + public static ContentType CreateSimpleContentType2(string alias, string name, IContentType parent = null, bool randomizeAliases = false, string propertyGroupAlias = "content", string propertyGroupName = "Content") { var builder = CreateSimpleContentTypeHelper(alias, name, parent, randomizeAliases: randomizeAliases, propertyGroupAlias: propertyGroupAlias, propertyGroupName: propertyGroupName); @@ -198,6 +208,9 @@ public static ContentType CreateSimpleContentType( int defaultTemplateId = 0) => (ContentType)CreateSimpleContentTypeHelper(alias, name, parent, propertyTypeCollection, randomizeAliases, propertyGroupAlias, propertyGroupName, mandatoryProperties, defaultTemplateId).Build(); + public static IContentType CreateSimpleElementType(string alias = "elementType", string name = "Element Type") + => CreateSimpleContentTypeHelper(alias, name).WithIsElement(true).Build(); + public static ContentTypeBuilder CreateSimpleContentTypeHelper( string alias = null, string name = null, @@ -254,13 +267,16 @@ public static ContentTypeBuilder CreateSimpleContentTypeHelper( .Done(); } - builder = builder - .AddAllowedTemplate() - .WithId(defaultTemplateId) - .WithAlias("textPage") - .WithName("Textpage") - .Done() - .WithDefaultTemplateId(defaultTemplateId); + if (defaultTemplateId > 0) + { + builder = builder + .AddAllowedTemplate() + .WithId(defaultTemplateId) + .WithAlias("textPage") + .WithName("Textpage") + .Done() + .WithDefaultTemplateId(defaultTemplateId); + } return builder; } diff --git a/tests/Umbraco.Tests.Common/Builders/ElementBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ElementBuilder.cs new file mode 100644 index 000000000000..6417893e2ae5 --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/ElementBuilder.cs @@ -0,0 +1,275 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Globalization; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Builders.Interfaces; +using Umbraco.Cms.Tests.Common.Extensions; + +namespace Umbraco.Cms.Tests.Common.Builders; + +public class ElementBuilder + : BuilderBase, + IBuildContentTypes, + IBuildContentCultureInfosCollection, + IWithIdBuilder, + IWithKeyBuilder, + IWithCreatorIdBuilder, + IWithCreateDateBuilder, + IWithUpdateDateBuilder, + IWithNameBuilder, + IWithTrashedBuilder, + IWithLevelBuilder, + IWithCultureInfoBuilder, + IWithPropertyValues +{ + private readonly IDictionary _cultureNames = new Dictionary(); + private ContentCultureInfosCollection _contentCultureInfosCollection; + private ContentCultureInfosCollectionBuilder _contentCultureInfosCollectionBuilder; + private IContentType _contentType; + private ContentTypeBuilder _contentTypeBuilder; + private DateTime? _createDate; + private int? _creatorId; + private CultureInfo _cultureInfo; + + private int? _id; + private Guid? _key; + private int? _level; + private string _name; + private GenericDictionaryBuilder _propertyDataBuilder; + private object _propertyValues; + private string _propertyValuesCulture; + private string _propertyValuesSegment; + private int? _sortOrder; + private bool? _trashed; + private DateTime? _updateDate; + private int? _versionId; + + DateTime? IWithCreateDateBuilder.CreateDate + { + get => _createDate; + set => _createDate = value; + } + + int? IWithCreatorIdBuilder.CreatorId + { + get => _creatorId; + set => _creatorId = value; + } + + CultureInfo IWithCultureInfoBuilder.CultureInfo + { + get => _cultureInfo; + set => _cultureInfo = value; + } + + int? IWithIdBuilder.Id + { + get => _id; + set => _id = value; + } + + Guid? IWithKeyBuilder.Key + { + get => _key; + set => _key = value; + } + + int? IWithLevelBuilder.Level + { + get => _level; + set => _level = value; + } + + string IWithNameBuilder.Name + { + get => _name; + set => _name = value; + } + + object IWithPropertyValues.PropertyValues + { + get => _propertyValues; + set => _propertyValues = value; + } + + string IWithPropertyValues.PropertyValuesCulture + { + get => _propertyValuesCulture; + set => _propertyValuesCulture = value; + } + + string IWithPropertyValues.PropertyValuesSegment + { + get => _propertyValuesSegment; + set => _propertyValuesSegment = value; + } + + bool? IWithTrashedBuilder.Trashed + { + get => _trashed; + set => _trashed = value; + } + + DateTime? IWithUpdateDateBuilder.UpdateDate + { + get => _updateDate; + set => _updateDate = value; + } + + public ElementBuilder WithVersionId(int versionId) + { + _versionId = versionId; + return this; + } + + public ElementBuilder WithContentType(IContentType contentType) + { + _contentTypeBuilder = null; + _contentType = contentType; + return this; + } + + public ElementBuilder WithContentCultureInfosCollection( + ContentCultureInfosCollection contentCultureInfosCollection) + { + _contentCultureInfosCollectionBuilder = null; + _contentCultureInfosCollection = contentCultureInfosCollection; + return this; + } + + public ElementBuilder WithCultureName(string culture, string name = "") + { + if (string.IsNullOrWhiteSpace(name)) + { + if (_cultureNames.TryGetValue(culture, out _)) + { + _cultureNames.Remove(culture); + } + } + else + { + _cultureNames[culture] = name; + } + + return this; + } + + public ContentTypeBuilder AddContentType() + { + _contentType = null; + var builder = new ContentTypeBuilder(this); + _contentTypeBuilder = builder; + return builder; + } + + public GenericDictionaryBuilder AddPropertyData() + { + var builder = new GenericDictionaryBuilder(this); + _propertyDataBuilder = builder; + return builder; + } + + public ContentCultureInfosCollectionBuilder AddContentCultureInfosCollection() + { + _contentCultureInfosCollection = null; + var builder = new ContentCultureInfosCollectionBuilder(this); + _contentCultureInfosCollectionBuilder = builder; + return builder; + } + + public override Element Build() + { + var id = _id ?? 0; + var versionId = _versionId ?? 0; + var key = _key ?? Guid.NewGuid(); + var createDate = _createDate ?? DateTime.Now; + var updateDate = _updateDate ?? DateTime.Now; + var name = _name ?? Guid.NewGuid().ToString(); + var creatorId = _creatorId ?? 0; + var level = _level ?? 1; + var path = $"-1,{id}"; + var sortOrder = _sortOrder ?? 0; + var trashed = _trashed ?? false; + var culture = _cultureInfo?.Name; + var propertyValues = _propertyValues; + var propertyValuesCulture = _propertyValuesCulture; + var propertyValuesSegment = _propertyValuesSegment; + + if (_contentTypeBuilder is null && _contentType is null) + { + throw new InvalidOperationException( + "A content item cannot be constructed without providing a content type. Use AddContentType() or WithContentType()."); + } + + var contentType = _contentType ?? _contentTypeBuilder.Build(); + + var element = new Element(name, contentType, culture) + { + Id = id, VersionId = versionId, Key = key, CreateDate = createDate, + UpdateDate = updateDate, + CreatorId = creatorId, + Level = level, + Path = path, + SortOrder = sortOrder, + Trashed = trashed + }; + + foreach (var cultureName in _cultureNames) + { + element.SetCultureName(cultureName.Value, cultureName.Key); + } + + if (_propertyDataBuilder != null || propertyValues != null) + { + if (_propertyDataBuilder != null) + { + var propertyData = _propertyDataBuilder.Build(); + foreach (var keyValuePair in propertyData) + { + element.SetValue(keyValuePair.Key, keyValuePair.Value); + } + } + else + { + element.PropertyValues(propertyValues, propertyValuesCulture, propertyValuesSegment); + } + + element.ResetDirtyProperties(false); + } + + if (_contentCultureInfosCollection is not null || _contentCultureInfosCollectionBuilder is not null) + { + var contentCultureInfos = + _contentCultureInfosCollection ?? _contentCultureInfosCollectionBuilder.Build(); + element.PublishCultureInfos = contentCultureInfos; + } + + return element; + } + + public static Element CreateBasicElement(IContentType contentType, int id = 0) => + new ElementBuilder() + .WithId(id) + .WithContentType(contentType) + .WithName("Element") + .Build(); + + public static Element CreateSimpleElement(IContentType contentType, string name = "Element", string? culture = null, + string? segment = null) + => new ElementBuilder() + .WithContentType(contentType) + .WithName(name) + .WithPropertyValues( + new + { + title = "This is the element title", + bodyText = "This is the element body text", + author = "Some One" + }, + culture, + segment) + .Build(); + +} diff --git a/tests/Umbraco.Tests.Common/Builders/UserBuilder.cs b/tests/Umbraco.Tests.Common/Builders/UserBuilder.cs index 529b186967bc..703abfc20f11 100644 --- a/tests/Umbraco.Tests.Common/Builders/UserBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/UserBuilder.cs @@ -48,6 +48,7 @@ public class UserBuilder private int? _sessionTimeout; private int[] _startContentIds; private int[] _startMediaIds; + private int[] _startElementIds; private string _suffix = string.Empty; private DateTime? _updateDate; private string _username; @@ -197,6 +198,18 @@ public UserBuilder WithStartMediaIds(int[] startMediaIds) return this; } + public UserBuilder WithStartElementId(int startElementId) + { + _startElementIds = new[] { startElementId }; + return this; + } + + public UserBuilder WithStartElementIds(int[] startElementIds) + { + _startElementIds = startElementIds; + return this; + } + public UserBuilder WithSuffix(string suffix) { _suffix = suffix; @@ -233,6 +246,7 @@ public override User Build() var sessionTimeout = _sessionTimeout ?? 0; var startContentIds = _startContentIds ?? new[] { -1 }; var startMediaIds = _startMediaIds ?? new[] { -1 }; + var startElementIds = _startElementIds ?? new[] { -1 }; var groups = _userGroupBuilders.Select(x => x.Build()); var result = new User( @@ -256,7 +270,8 @@ public override User Build() Comments = comments, SessionTimeout = sessionTimeout, StartContentIds = startContentIds, - StartMediaIds = startMediaIds + StartMediaIds = startMediaIds, + StartElementIds = startElementIds }; foreach (var readOnlyUserGroup in groups) { diff --git a/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs b/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs index b06c97b41989..c7af53299aaf 100644 --- a/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs @@ -37,6 +37,7 @@ public class UserGroupBuilder private ISet _permissions = new HashSet(); private int? _startContentId; private int? _startMediaId; + private int? _startElementId; private string _suffix; private int? _userCount; @@ -128,6 +129,12 @@ public UserGroupBuilder WithStartMediaId(int startMediaId) return this; } + public UserGroupBuilder WithStartElementId(int startElementId) + { + _startElementId = startElementId; + return this; + } + public IReadOnlyUserGroup BuildReadOnly(IUserGroup userGroup) => Mock.Of(x => x.Permissions == userGroup.Permissions && @@ -136,6 +143,7 @@ public IReadOnlyUserGroup BuildReadOnly(IUserGroup userGroup) => x.Name == userGroup.Name && x.StartContentId == userGroup.StartContentId && x.StartMediaId == userGroup.StartMediaId && + x.StartElementId == userGroup.StartElementId && x.AllowedSections == userGroup.AllowedSections && x.Id == userGroup.Id && x.Key == userGroup.Key); @@ -149,6 +157,7 @@ public override IUserGroup Build() var userCount = _userCount ?? 0; var startContentId = _startContentId ?? -1; var startMediaId = _startMediaId ?? -1; + var startElementId = _startElementId ?? -1; var icon = _icon ?? "icon-group"; var shortStringHelper = new DefaultShortStringHelper(new DefaultShortStringHelperConfig()); @@ -159,6 +168,7 @@ public override IUserGroup Build() Key = key, StartContentId = startContentId, StartMediaId = startMediaId, + StartElementId = startElementId, Permissions = _permissions, }; diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/ByKeyElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/ByKeyElementControllerTests.cs new file mode 100644 index 000000000000..3d6e6323fffc --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/ByKeyElementControllerTests.cs @@ -0,0 +1,63 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class ByKeyElementControllerTests : ManagementApiUserGroupTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + // Create element type + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Create element + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = "Test Element Instance" }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + } + + protected override Expression> MethodSelector => + x => x.ByKey(CancellationToken.None, _elementKey); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/ConfigurationElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/ConfigurationElementControllerTests.cs new file mode 100644 index 000000000000..8d43f483157d --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/ConfigurationElementControllerTests.cs @@ -0,0 +1,29 @@ +using System.Linq.Expressions; +using System.Net; +using Umbraco.Cms.Api.Management.Controllers.Element; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class ConfigurationElementControllerTests : ManagementApiUserGroupTestBase +{ + protected override Expression> MethodSelector => + x => x.Configuration(CancellationToken.None); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/CopyElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/CopyElementControllerTests.cs new file mode 100644 index 000000000000..9211d226f5ee --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/CopyElementControllerTests.cs @@ -0,0 +1,83 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class CopyElementControllerTests : ManagementApiUserGroupTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + private Guid _targetContainerKey; + + [SetUp] + public async Task Setup() + { + // Create element type + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Create element to copy + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = "Element to Copy" }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + + // Create target container + var targetResult = await ElementContainerService.CreateAsync(null, Guid.NewGuid().ToString(), null, Constants.Security.SuperUserKey); + _targetContainerKey = targetResult.Result!.Key; + } + + protected override Expression> MethodSelector => + x => x.Copy(CancellationToken.None, _elementKey, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Created }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Created }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var copyElementRequestModel = new CopyElementRequestModel + { + Target = new ReferenceByIdModel(_targetContainerKey), + }; + + return await Client.PostAsync(Url, JsonContent.Create(copyElementRequestModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/CreateElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/CreateElementControllerTests.cs new file mode 100644 index 000000000000..f2287b517bad --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/CreateElementControllerTests.cs @@ -0,0 +1,71 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class CreateElementControllerTests : ManagementApiUserGroupTestBase +{ + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementTypeKey; + + [SetUp] + public async Task Setup() + { + // Create element type + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + _elementTypeKey = elementType.Key; + } + + protected override Expression> MethodSelector => + x => x.Create(CancellationToken.None, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Created }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Created }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Created }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var createElementRequestModel = new CreateElementRequestModel + { + DocumentType = new ReferenceByIdModel(_elementTypeKey), + Parent = null, + Id = Guid.NewGuid(), + Values = [], + Variants = + [ + new ElementVariantRequestModel { Culture = null, Segment = null, Name = "Test Element Instance" } + ], + }; + + return await Client.PostAsync(Url, JsonContent.Create(createElementRequestModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/DeleteElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/DeleteElementControllerTests.cs new file mode 100644 index 000000000000..a9618cd2b910 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/DeleteElementControllerTests.cs @@ -0,0 +1,67 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class DeleteElementControllerTests : ManagementApiUserGroupTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementTypeKey; + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + _elementTypeKey = elementType.Key; + + // Create a new element for each test since delete removes it + var createModel = new ElementCreateModel + { + ContentTypeKey = _elementTypeKey, + ParentKey = null, + Variants = [new VariantModel { Name = Guid.NewGuid().ToString() }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + } + + protected override Expression> MethodSelector => + x => x.Delete(CancellationToken.None, _elementKey); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + => await Client.DeleteAsync(Url); +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/ByKeyElementFolderControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/ByKeyElementFolderControllerTests.cs new file mode 100644 index 000000000000..c00909acc19d --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/ByKeyElementFolderControllerTests.cs @@ -0,0 +1,43 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.Folder; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Folder; + +public class ByKeyElementFolderControllerTests : ManagementApiUserGroupTestBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private Guid _folderKey; + + [SetUp] + public async Task Setup() + { + var result = await ElementContainerService.CreateAsync(null, Guid.NewGuid().ToString(), null, Constants.Security.SuperUserKey); + _folderKey = result.Result!.Key; + } + + protected override Expression> MethodSelector => + x => x.ByKey(CancellationToken.None, _folderKey); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/CreateElementFolderControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/CreateElementFolderControllerTests.cs new file mode 100644 index 000000000000..d806ce377bcb --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/CreateElementFolderControllerTests.cs @@ -0,0 +1,43 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using Umbraco.Cms.Api.Management.Controllers.Element.Folder; +using Umbraco.Cms.Api.Management.ViewModels.Folder; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Folder; + +public class CreateElementFolderControllerTests : ManagementApiUserGroupTestBase +{ + protected override Expression> MethodSelector => + x => x.Create(CancellationToken.None, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Created }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Created }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Created }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var createModel = new CreateFolderRequestModel + { + Name = Guid.NewGuid().ToString(), + Parent = null, + Id = Guid.NewGuid(), + }; + + return await Client.PostAsync(Url, JsonContent.Create(createModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/DeleteElementFolderControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/DeleteElementFolderControllerTests.cs new file mode 100644 index 000000000000..eb1e0b954d8c --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/DeleteElementFolderControllerTests.cs @@ -0,0 +1,46 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.Folder; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Folder; + +public class DeleteElementFolderControllerTests : ManagementApiUserGroupTestBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private Guid _folderKey; + + [SetUp] + public async Task Setup() + { + var result = await ElementContainerService.CreateAsync(null, Guid.NewGuid().ToString(), null, Constants.Security.SuperUserKey); + _folderKey = result.Result!.Key; + } + + protected override Expression> MethodSelector => + x => x.Delete(CancellationToken.None, _folderKey); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + => await Client.DeleteAsync(Url); +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/MoveElementFolderControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/MoveElementFolderControllerTests.cs new file mode 100644 index 000000000000..8ffe826fb641 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/MoveElementFolderControllerTests.cs @@ -0,0 +1,60 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.Folder; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Folder; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Folder; + +public class MoveElementFolderControllerTests : ManagementApiUserGroupTestBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private Guid _folderKey; + private Guid _targetFolderKey; + + [SetUp] + public async Task Setup() + { + var folderResult = await ElementContainerService.CreateAsync(null, Guid.NewGuid().ToString(), null, Constants.Security.SuperUserKey); + _folderKey = folderResult.Result!.Key; + + var targetResult = await ElementContainerService.CreateAsync(null, Guid.NewGuid().ToString(), null, Constants.Security.SuperUserKey); + _targetFolderKey = targetResult.Result!.Key; + } + + protected override Expression> MethodSelector => + x => x.Move(CancellationToken.None, _folderKey, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var moveModel = new MoveFolderRequestModel + { + Target = new ReferenceByIdModel(_targetFolderKey), + }; + + return await Client.PutAsync(Url, JsonContent.Create(moveModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/MoveToRecycleBinElementFolderControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/MoveToRecycleBinElementFolderControllerTests.cs new file mode 100644 index 000000000000..870896ce65eb --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/MoveToRecycleBinElementFolderControllerTests.cs @@ -0,0 +1,46 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.Folder; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Folder; + +public class MoveToRecycleBinElementFolderControllerTests : ManagementApiUserGroupTestBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private Guid _folderKey; + + [SetUp] + public async Task Setup() + { + var result = await ElementContainerService.CreateAsync(null, Guid.NewGuid().ToString(), null, Constants.Security.SuperUserKey); + _folderKey = result.Result!.Key; + } + + protected override Expression> MethodSelector => + x => x.Move(CancellationToken.None, _folderKey); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + => await Client.PutAsync(Url, null); +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/UpdateElementFolderControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/UpdateElementFolderControllerTests.cs new file mode 100644 index 000000000000..2c075ef7028f --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Folder/UpdateElementFolderControllerTests.cs @@ -0,0 +1,55 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.Folder; +using Umbraco.Cms.Api.Management.ViewModels.Folder; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Folder; + +public class UpdateElementFolderControllerTests : ManagementApiUserGroupTestBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private Guid _folderKey; + + [SetUp] + public async Task Setup() + { + var result = await ElementContainerService.CreateAsync(null, Guid.NewGuid().ToString(), null, Constants.Security.SuperUserKey); + _folderKey = result.Result!.Key; + } + + protected override Expression> MethodSelector => + x => x.Update(CancellationToken.None, _folderKey, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var updateModel = new UpdateFolderResponseModel + { + Name = Guid.NewGuid().ToString(), + }; + + return await Client.PutAsync(Url, JsonContent.Create(updateModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Item/ItemElementItemControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Item/ItemElementItemControllerTests.cs new file mode 100644 index 000000000000..8282d58dca29 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Item/ItemElementItemControllerTests.cs @@ -0,0 +1,64 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.Item; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Item; + +public class ItemElementItemControllerTests : ManagementApiUserGroupTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = Guid.NewGuid().ToString() }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + } + + protected override Expression> MethodSelector => + x => x.Item(CancellationToken.None, new HashSet { _elementKey }); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + => await Client.GetAsync($"{Url}?id={_elementKey}"); +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/MoveElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/MoveElementControllerTests.cs new file mode 100644 index 000000000000..df6ca510aa56 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/MoveElementControllerTests.cs @@ -0,0 +1,83 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class MoveElementControllerTests : ManagementApiUserGroupTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + private Guid _targetContainerKey; + + [SetUp] + public async Task Setup() + { + // Create element type + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Create element to move + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = "Element to Move" }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + + // Create target container + var targetResult = await ElementContainerService.CreateAsync(null, Guid.NewGuid().ToString(), null, Constants.Security.SuperUserKey); + _targetContainerKey = targetResult.Result!.Key; + } + + protected override Expression> MethodSelector => + x => x.Move(CancellationToken.None, _elementKey, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var moveElementRequestModel = new MoveElementRequestModel + { + Target = new ReferenceByIdModel(_targetContainerKey), + }; + + return await Client.PutAsync(Url, JsonContent.Create(moveElementRequestModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/MoveToRecycleBinElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/MoveToRecycleBinElementControllerTests.cs new file mode 100644 index 000000000000..efa89019e47c --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/MoveToRecycleBinElementControllerTests.cs @@ -0,0 +1,66 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class MoveToRecycleBinElementControllerTests : ManagementApiUserGroupTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementTypeKey; + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + _elementTypeKey = elementType.Key; + + var createModel = new ElementCreateModel + { + ContentTypeKey = _elementTypeKey, + ParentKey = null, + Variants = [new VariantModel { Name = Guid.NewGuid().ToString() }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + } + + protected override Expression> MethodSelector => + x => x.MoveToRecycleBin(CancellationToken.None, _elementKey); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + => await Client.PutAsync(Url, null); +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/PublishElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/PublishElementControllerTests.cs new file mode 100644 index 000000000000..d2b85dc48500 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/PublishElementControllerTests.cs @@ -0,0 +1,74 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class PublishElementControllerTests : ManagementApiUserGroupTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = "Test Element" }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + } + + protected override Expression> MethodSelector => + x => x.Publish(CancellationToken.None, _elementKey, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var publishModel = new PublishElementRequestModel + { + PublishSchedules = [new CultureAndScheduleRequestModel { Culture = null }], + }; + + return await Client.PutAsync(Url, JsonContent.Create(publishModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/ChildrenElementRecycleBinControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/ChildrenElementRecycleBinControllerTests.cs new file mode 100644 index 000000000000..2909cd94c02c --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/ChildrenElementRecycleBinControllerTests.cs @@ -0,0 +1,71 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.RecycleBin; + +public class ChildrenElementRecycleBinControllerTests : ElementRecycleBinControllerTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _folderKey; + + [SetUp] + public async Task Setup() + { + // Folder + var folderResult = await ElementContainerService.CreateAsync(null, Guid.NewGuid().ToString(), null, Constants.Security.SuperUserKey); + _folderKey = folderResult.Result!.Key; + + // Element Type + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Element inside folder + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = _folderKey, + Variants = [new VariantModel { Name = Guid.NewGuid().ToString() }], + }; + await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + + // Move folder to recycle bin (this will move the element inside it too) + await ElementContainerService.MoveToRecycleBinAsync(_folderKey, Constants.Security.SuperUserKey); + } + + protected override Expression> MethodSelector => + x => x.Children(CancellationToken.None, _folderKey, 0, 100); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/DeleteElementFolderRecycleBinControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/DeleteElementFolderRecycleBinControllerTests.cs new file mode 100644 index 000000000000..421966186b36 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/DeleteElementFolderRecycleBinControllerTests.cs @@ -0,0 +1,48 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.RecycleBin; + +public class DeleteElementFolderRecycleBinControllerTests : ElementRecycleBinControllerTestBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private Guid _folderKey; + + [SetUp] + public async Task Setup() + { + var result = await ElementContainerService.CreateAsync(null, Guid.NewGuid().ToString(), null, Constants.Security.SuperUserKey); + _folderKey = result.Result!.Key; + + await ElementContainerService.MoveToRecycleBinAsync(_folderKey, Constants.Security.SuperUserKey); + } + + protected override Expression> MethodSelector => + x => x.Delete(CancellationToken.None, _folderKey); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + => await Client.DeleteAsync(Url); +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/DeleteElementRecycleBinControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/DeleteElementRecycleBinControllerTests.cs new file mode 100644 index 000000000000..7034fb5f61f0 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/DeleteElementRecycleBinControllerTests.cs @@ -0,0 +1,66 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.RecycleBin; + +public class DeleteElementRecycleBinControllerTests : ElementRecycleBinControllerTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = Guid.NewGuid().ToString() }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + + await ElementEditingService.MoveToRecycleBinAsync(_elementKey, Constants.Security.SuperUserKey); + } + + protected override Expression> MethodSelector => + x => x.Delete(CancellationToken.None, _elementKey); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + => await Client.DeleteAsync(Url); +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/ElementRecycleBinControllerTestBase.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/ElementRecycleBinControllerTestBase.cs new file mode 100644 index 000000000000..ca00179e8978 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/ElementRecycleBinControllerTestBase.cs @@ -0,0 +1,49 @@ +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.RecycleBin; + +public abstract class ElementRecycleBinControllerTestBase : ManagementApiUserGroupTestBase + where T : ElementRecycleBinControllerBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IUserGroupService UserGroupService => GetRequiredService(); + + [Test] + public async Task User_With_Non_Root_Element_Start_Node_Cannot_Access_Recycle_Bin() + { + // Create an element container (folder) to use as a non-root start node + var containerResult = await ElementContainerService.CreateAsync( + null, + $"Test Folder {Guid.NewGuid()}", + null, // at root + Constants.Security.SuperUserKey); + Assert.IsTrue(containerResult.Success); + var container = containerResult.Result!; + + // Create a user group with Library section access but with a non-root element start node + var userGroup = new UserGroupBuilder() + .WithAlias(Guid.NewGuid().ToString("N")) + .WithName("Test Group With Element Start Node") + .WithAllowedSections(["library"]) + .WithStartElementId(container.Id) + .Build(); + + await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserKey); + + // Authenticate as a user in the group with non-root element start node + await AuthenticateClientAsync(Client, $"startnodetest{Guid.NewGuid():N}@umbraco.com", "1234567890", userGroup.Key); + + // Try to access the recycle bin + var response = await ClientRequest(); + + // Should be forbidden because user doesn't have root element access + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode, await response.Content.ReadAsStringAsync()); + } +} \ No newline at end of file diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/EmptyElementRecycleBinControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/EmptyElementRecycleBinControllerTests.cs new file mode 100644 index 000000000000..e7b917ef4e1a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/EmptyElementRecycleBinControllerTests.cs @@ -0,0 +1,64 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.RecycleBin; + +public class EmptyElementRecycleBinControllerTests : ElementRecycleBinControllerTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = Guid.NewGuid().ToString() }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + var elementKey = response.Result!.Content!.Key; + + await ElementEditingService.MoveToRecycleBinAsync(elementKey, Constants.Security.SuperUserKey); + } + + protected override Expression> MethodSelector => + x => x.EmptyRecycleBin(CancellationToken.None); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + => await Client.DeleteAsync(Url); +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/RootElementRecycleBinControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/RootElementRecycleBinControllerTests.cs new file mode 100644 index 000000000000..8c6b3a967a0b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/RootElementRecycleBinControllerTests.cs @@ -0,0 +1,63 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.RecycleBin; + +public class RootElementRecycleBinControllerTests : ElementRecycleBinControllerTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = Guid.NewGuid().ToString() }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + + await ElementEditingService.MoveToRecycleBinAsync(_elementKey, Constants.Security.SuperUserKey); + } + + protected override Expression> MethodSelector => + x => x.Root(CancellationToken.None, 0, 100); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/SiblingsElementRecycleBinControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/SiblingsElementRecycleBinControllerTests.cs new file mode 100644 index 000000000000..ae79202c58f6 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/RecycleBin/SiblingsElementRecycleBinControllerTests.cs @@ -0,0 +1,75 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.RecycleBin; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.RecycleBin; + +public class SiblingsElementRecycleBinControllerTests : ElementRecycleBinControllerTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + // Element Type + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Create two elements at root so we have siblings in the recycle bin + var createModel1 = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = Guid.NewGuid().ToString() }], + }; + var response1 = await ElementEditingService.CreateAsync(createModel1, Constants.Security.SuperUserKey); + _elementKey = response1.Result!.Content!.Key; + + var createModel2 = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = Guid.NewGuid().ToString() }], + }; + var response2 = await ElementEditingService.CreateAsync(createModel2, Constants.Security.SuperUserKey); + + // Move both to recycle bin + await ElementEditingService.MoveToRecycleBinAsync(_elementKey, Constants.Security.SuperUserKey); + await ElementEditingService.MoveToRecycleBinAsync(response2.Result!.Content!.Key, Constants.Security.SuperUserKey); + } + + protected override Expression> MethodSelector => + x => x.Siblings(CancellationToken.None, _elementKey, 0, 10, null); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/AncestorsElementTreeControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/AncestorsElementTreeControllerTests.cs new file mode 100644 index 000000000000..174651f88927 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/AncestorsElementTreeControllerTests.cs @@ -0,0 +1,90 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element.Tree; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Tree; + +public class AncestorsElementTreeControllerTests : ManagementApiUserGroupTestBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IUserGroupService UserGroupService => GetRequiredService(); + + private Guid _grandparentKey; + private Guid _parentKey; + private int _parentId; + + [SetUp] + public async Task Setup() + { + // Create grandparent container + var grandparentResult = await ElementContainerService.CreateAsync(null, $"GrandparentContainer {Guid.NewGuid()}", null, Constants.Security.SuperUserKey); + Assert.IsTrue(grandparentResult.Success, $"Failed to create grandparent: {grandparentResult.Status}"); + _grandparentKey = grandparentResult.Result!.Key; + + // Create parent container + var parentResult = await ElementContainerService.CreateAsync(null, $"ParentContainer {Guid.NewGuid()}", _grandparentKey, Constants.Security.SuperUserKey); + Assert.IsTrue(parentResult.Success, $"Failed to create parent: {parentResult.Status}"); + _parentKey = parentResult.Result!.Key; + _parentId = parentResult.Result!.Id; + } + + protected override Expression> MethodSelector => + x => x.Ancestors(CancellationToken.None, _parentKey); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + [Test] + public async Task User_With_Start_Node_Can_Access_Ancestors() + { + // Create a user group with Library section access and start node = parent folder + var userGroup = new UserGroupBuilder() + .WithAlias(Guid.NewGuid().ToString("N")) + .WithName("Test Group With Element Start Node") + .WithAllowedSections(["library"]) + .WithStartElementId(_parentId) + .Build(); + + await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserKey); + + // Authenticate as a user in the group with the restricted start node + await AuthenticateClientAsync(Client, $"startnodetest{Guid.NewGuid():N}@umbraco.com", "1234567890", userGroup.Key); + + // Get ancestors of the parent folder (user's start node) + var response = await ClientRequest(); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, await response.Content.ReadAsStringAsync()); + + var result = await response.Content.ReadFromJsonAsync>(JsonSerializerOptions); + Assert.IsNotNull(result); + var ancestors = result.ToList(); + + // Ancestors should include grandparent and parent (the target itself) + Assert.AreEqual(2, ancestors.Count, "Should return grandparent and parent"); + Assert.AreEqual(_grandparentKey, ancestors[0].Id, "First ancestor should be grandparent"); + Assert.AreEqual(_parentKey, ancestors[1].Id, "Second ancestor should be parent (target)"); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/ChildrenElementTreeControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/ChildrenElementTreeControllerTests.cs new file mode 100644 index 000000000000..d044a8a78027 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/ChildrenElementTreeControllerTests.cs @@ -0,0 +1,116 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Controllers.Element.Tree; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Tree; + +public class ChildrenElementTreeControllerTests : ManagementApiUserGroupTestBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IUserGroupService UserGroupService => GetRequiredService(); + + private Guid _parentKey; + + [SetUp] + public async Task Setup() + { + // Create element type + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName($"Test Element {Guid.NewGuid()}") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Create parent container + var parentResult = await ElementContainerService.CreateAsync(null, $"ParentContainer {Guid.NewGuid()}", null, Constants.Security.SuperUserKey); + Assert.IsTrue(parentResult.Success, $"Failed to create parent: {parentResult.Status}"); + _parentKey = parentResult.Result!.Key; + + // Create child container + var childContainerResult = await ElementContainerService.CreateAsync(null, $"ChildContainer {Guid.NewGuid()}", _parentKey, Constants.Security.SuperUserKey); + Assert.IsTrue(childContainerResult.Success, $"Failed to create child container: {childContainerResult.Status}"); + + // Create child element + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = _parentKey, + Variants = [new VariantModel { Name = $"ChildElement {Guid.NewGuid()}" }], + }; + await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + } + + protected override Expression> MethodSelector => + x => x.Children(CancellationToken.None, _parentKey, 0, 100, false); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + [Test] + public async Task User_With_Start_Node_Cannot_Access_Children_Outside_Start_Node() + { + // Create another folder to be the user's start node (different from the one in Setup) + var startNodeResult = await ElementContainerService.CreateAsync( + null, + $"Start Node Folder {Guid.NewGuid()}", + _parentKey, + Constants.Security.SuperUserKey); + Assert.IsTrue(startNodeResult.Success, $"Failed to create start node folder: {startNodeResult.Status}"); + var startNodeFolder = startNodeResult.Result!; + + // Create a user group with Library section access but with a non-root element start node + var userGroup = new UserGroupBuilder() + .WithAlias(Guid.NewGuid().ToString("N")) + .WithName("Test Group With Element Start Node") + .WithAllowedSections(["library"]) + .WithStartElementId(startNodeFolder.Id) + .Build(); + + await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserKey); + + // Authenticate as a user in the group with the restricted start node + await AuthenticateClientAsync(Client, $"startnodetest{Guid.NewGuid():N}@umbraco.com", "1234567890", userGroup.Key); + + // Try to access the children of the parent folder created in Setup (which is outside the start node) + var response = await ClientRequest(); + + // Should succeed + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, await response.Content.ReadAsStringAsync()); + + // Parse response and verify only folder1 is returned + var result = await response.Content.ReadFromJsonAsync>(JsonSerializerOptions); + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Total); + Assert.AreEqual(startNodeFolder.Key, result.Items.First().Id); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/RootElementTreeControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/RootElementTreeControllerTests.cs new file mode 100644 index 000000000000..a25a990faa90 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/RootElementTreeControllerTests.cs @@ -0,0 +1,87 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Controllers.Element.Tree; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Tree; + +public class RootElementTreeControllerTests : ManagementApiUserGroupTestBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IUserGroupService UserGroupService => GetRequiredService(); + + protected override Expression> MethodSelector => + x => x.Root(CancellationToken.None, 0, 100, false); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + [Test] + public async Task User_With_Start_Node_Only_Sees_Permitted_Roots() + { + // Create two folders at root level + var folder1Result = await ElementContainerService.CreateAsync( + null, + $"Folder 1 {Guid.NewGuid()}", + null, + Constants.Security.SuperUserKey); + Assert.IsTrue(folder1Result.Success); + var folder1 = folder1Result.Result!; + + var folder2Result = await ElementContainerService.CreateAsync( + null, + $"Folder 2 {Guid.NewGuid()}", + null, + Constants.Security.SuperUserKey); + Assert.IsTrue(folder2Result.Success); + + // Create a user group with start node = folder1 only + var userGroup = new UserGroupBuilder() + .WithAlias(Guid.NewGuid().ToString("N")) + .WithName("Test Group With Element Start Node") + .WithAllowedSections(["library"]) + .WithStartElementId(folder1.Id) + .Build(); + + await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserKey); + + // Authenticate as a user in that group + await AuthenticateClientAsync(Client, $"startnodetest{Guid.NewGuid():N}@umbraco.com", "1234567890", userGroup.Key); + + // Get root tree items + var response = await ClientRequest(); + + // Should succeed + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, await response.Content.ReadAsStringAsync()); + + // Parse response and verify only folder1 is returned + var result = await response.Content.ReadFromJsonAsync>(JsonSerializerOptions); + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Total); + Assert.AreEqual(folder1.Key, result.Items.First().Id); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/SiblingsElementTreeControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/SiblingsElementTreeControllerTests.cs new file mode 100644 index 000000000000..2a45ed7e805c --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/Tree/SiblingsElementTreeControllerTests.cs @@ -0,0 +1,90 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Controllers.Element.Tree; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element.Tree; + +public class SiblingsElementTreeControllerTests : ManagementApiUserGroupTestBase +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IUserGroupService UserGroupService => GetRequiredService(); + + private Guid _folder1Key; + private int _folder1Id; + + [SetUp] + public async Task Setup() + { + // Create two folders at root level (siblings of each other) + var folder1Result = await ElementContainerService.CreateAsync(null, $"Folder1 {Guid.NewGuid()}", null, Constants.Security.SuperUserKey); + Assert.IsTrue(folder1Result.Success, $"Failed to create folder1: {folder1Result.Status}"); + _folder1Key = folder1Result.Result!.Key; + _folder1Id = folder1Result.Result!.Id; + + var folder2Result = await ElementContainerService.CreateAsync(null, $"Folder2 {Guid.NewGuid()}", null, Constants.Security.SuperUserKey); + Assert.IsTrue(folder2Result.Success, $"Failed to create folder2: {folder2Result.Status}"); + } + + protected override Expression> MethodSelector => + x => x.Siblings(CancellationToken.None, _folder1Key, 10, 10, false); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + [Test] + public async Task User_With_Start_Node_Only_Sees_Accessible_Siblings() + { + // User's start node is folder1, so they have no access to folder2 (its sibling) + var userGroup = new UserGroupBuilder() + .WithAlias(Guid.NewGuid().ToString("N")) + .WithName("Test Group With Element Start Node") + .WithAllowedSections(["library"]) + .WithStartElementId(_folder1Id) + .Build(); + + await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserKey); + + await AuthenticateClientAsync(Client, $"startnodetest{Guid.NewGuid():N}@umbraco.com", "1234567890", userGroup.Key); + + // Get siblings of folder1 (folder2 is a sibling but user has no access) + var response = await ClientRequest(); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, await response.Content.ReadAsStringAsync()); + + var result = await response.Content.ReadFromJsonAsync>(JsonSerializerOptions); + Assert.IsNotNull(result); + + // Only folder1 (the target) should be returned; folder2 should be filtered out completely + Assert.AreEqual(1, result.Items.Count(), "Only the target folder should be returned"); + Assert.AreEqual(_folder1Key, result.Items.First().Id, "The target folder should be folder1"); + + // No accessible siblings before or after the target + Assert.AreEqual(0, result.TotalBefore, "No accessible siblings before"); + Assert.AreEqual(0, result.TotalAfter, "No accessible siblings after"); + } +} + diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/UnpublishElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/UnpublishElementControllerTests.cs new file mode 100644 index 000000000000..0f415b117347 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/UnpublishElementControllerTests.cs @@ -0,0 +1,79 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class UnpublishElementControllerTests : ManagementApiUserGroupTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IElementPublishingService ElementPublishingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = "Test Element" }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + + // Publish the element so we can unpublish it + await ElementPublishingService.PublishAsync(_elementKey, [], Constants.Security.SuperUserKey); + } + + protected override Expression> MethodSelector => + x => x.Unpublish(CancellationToken.None, _elementKey, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var unpublishModel = new UnpublishElementRequestModel + { + Cultures = null, + }; + + return await Client.PutAsync(Url, JsonContent.Create(unpublishModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/UpdateElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/UpdateElementControllerTests.cs new file mode 100644 index 000000000000..59437482662f --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/UpdateElementControllerTests.cs @@ -0,0 +1,74 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class UpdateElementControllerTests : ManagementApiUserGroupTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = "Test Element" }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + } + + protected override Expression> MethodSelector => + x => x.Update(CancellationToken.None, _elementKey, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var updateModel = new UpdateElementRequestModel + { + Values = [], + Variants = [new ElementVariantRequestModel { Culture = null, Segment = null, Name = "Updated Element" }], + }; + + return await Client.PutAsync(Url, JsonContent.Create(updateModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/ValidateCreateElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/ValidateCreateElementControllerTests.cs new file mode 100644 index 000000000000..41e473a8e531 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/ValidateCreateElementControllerTests.cs @@ -0,0 +1,67 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class ValidateCreateElementControllerTests : ManagementApiUserGroupTestBase +{ + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementTypeKey; + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + _elementTypeKey = elementType.Key; + } + + protected override Expression> MethodSelector => + x => x.Validate(CancellationToken.None, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var createModel = new CreateElementRequestModel + { + DocumentType = new ReferenceByIdModel(_elementTypeKey), + Parent = null, + Id = Guid.NewGuid(), + Values = [], + Variants = [new ElementVariantRequestModel { Culture = null, Segment = null, Name = "Test Element" }], + }; + + return await Client.PostAsync(Url, JsonContent.Create(createModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Element/ValidateUpdateElementControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Element/ValidateUpdateElementControllerTests.cs new file mode 100644 index 000000000000..75bee4c2d711 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Element/ValidateUpdateElementControllerTests.cs @@ -0,0 +1,75 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Element; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Element; + +public class ValidateUpdateElementControllerTests : ManagementApiUserGroupTestBase +{ + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _elementKey; + + [SetUp] + public async Task Setup() + { + var elementType = new ContentTypeBuilder() + .WithAlias(Guid.NewGuid().ToString()) + .WithName("Test Element") + .WithIsElement(true) + .Build(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new VariantModel { Name = "Test Element" }], + }; + var response = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _elementKey = response.Result!.Content!.Key; + } + + protected override Expression> MethodSelector => + x => x.Validate(CancellationToken.None, _elementKey, null!); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Forbidden }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; + + protected override async Task ClientRequest() + { + var validateModel = new ValidateUpdateElementRequestModel + { + Values = [], + Variants = [new ElementVariantRequestModel { Culture = null, Segment = null, Name = "Updated Element" }], + Cultures = null, + }; + + return await Client.PutAsync(Url, JsonContent.Create(validateModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs index f9d103f1e44b..9c1a9e5f8ce3 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs @@ -50,7 +50,6 @@ protected override void ConfigureTestServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); - } [Test] @@ -99,6 +98,8 @@ public async Task Can_Create_Current_User_Response_Model() Assert.IsFalse(model.HasMediaRootAccess); Assert.AreEqual(1, model.MediaStartNodeIds.Count); Assert.AreEqual(rootMediaFolder.Key, model.MediaStartNodeIds.First().Id); + Assert.IsTrue(model.HasElementRootAccess); + Assert.AreEqual(0, model.ElementStartNodeIds.Count); Assert.IsFalse(model.HasAccessToSensitiveData); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs b/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs index 598cfc015056..b9682e1693e6 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs @@ -5,9 +5,12 @@ using System.Net.Mime; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Text.Json.Serialization; using System.Web; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using NUnit.Framework; using OpenIddict.Abstractions; using Umbraco.Cms.Api.Management.Controllers; @@ -32,10 +35,20 @@ namespace Umbraco.Cms.Tests.Integration.ManagementApi; public abstract class ManagementApiTest : UmbracoTestServerTestBase where T : ManagementApiControllerBase { - private static readonly Dictionary _tokenCache = new(); private static readonly SHA256 _sha256 = SHA256.Create(); + protected JsonSerializerOptions JsonSerializerOptions + { + get + { + var options = GetRequiredService>(); + return options + .Get(Constants.JsonOptionsNames.BackOffice) + .JsonSerializerOptions; + } + } + protected abstract Expression> MethodSelector { get; set; } protected string Url => GetManagementApiUrl(MethodSelector); diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.ChildUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.ChildUserAccessEntities.cs new file mode 100644 index 000000000000..5683c2947647 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.ChildUserAccessEntities.cs @@ -0,0 +1,257 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceElementTests +{ + [Test] + public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFirstPage_AsAllowed() + { + // Child containers "C1-C1" and "C1-C10" are used as start nodes + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C1-C1"].Id, ItemsByName["C1-C10"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C1"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + // expected total is 2, because only two items under "C1" 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 child containers are the ones allowed + Assert.AreEqual(ItemsByName["C1-C1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["C1-C10"].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_InAndOutOfScope_YieldsOnlyChildrenInScope() + { + // Child containers "C1-C5" and "C2-C10" are used as start nodes + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C1-C5"].Id, ItemsByName["C2-C10"].Id); + Assert.AreEqual(2, elementStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C2"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(1, totalItems); + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + // only the "C2-C10" container is returned, as "C1-C5" is out of scope + Assert.AreEqual(ItemsByName["C2-C10"].Key, children[0].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing() + { + // Child containers "C1-C5" and "C2-C10" are used as start nodes + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C1-C5"].Id, ItemsByName["C2-C10"].Id); + Assert.AreEqual(2, elementStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(0, totalItems); + Assert.AreEqual(0, children.Length); + } + + [Test] + public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginate() + { + // Child containers used as start nodes + var elementStartNodePaths = await CreateUserAndGetStartNodePaths( + ItemsByName["C1-C1"].Id, + ItemsByName["C1-C3"].Id, + ItemsByName["C1-C5"].Id, + ItemsByName["C1-C7"].Id, + ItemsByName["C1-C9"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C1"].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(ItemsByName["C1-C1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["C1-C3"].Key, children[1].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + + // next result page + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C1"].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(ItemsByName["C1-C5"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["C1-C7"].Key, children[1].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + + // next result page + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C1"].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(ItemsByName["C1-C9"].Key, children[0].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ChildContainerAsStartNode_YieldsAllGrandchildren_AsAllowed() + { + // Child container "C3-C3" is used as start node + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C3-C3"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C3-C3"].Key, + 0, + 100, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + Assert.AreEqual(5, children.Length); + Assert.Multiple(() => + { + // all children of "C3-C3" (which are elements) should be allowed because "C3-C3" is a start node + foreach (var childNumber in Enumerable.Range(1, 5)) + { + var child = children[childNumber - 1]; + Assert.AreEqual(ItemsByName[$"C3-C3-E{childNumber}"].Key, child.Entity.Key); + Assert.IsTrue(child.HasAccess); + } + }); + } + + [Test] + public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectResultOrder() + { + // Child containers "C3-C3", "C3-C2", "C3-C1" are used as start nodes (in reverse order) + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C3-C3"].Id, ItemsByName["C3-C2"].Id, ItemsByName["C3-C1"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + Assert.AreEqual(3, totalItems); + Assert.AreEqual(3, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(ItemsByName["C3-C1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["C3-C2"].Key, children[1].Entity.Key); + Assert.AreEqual(ItemsByName["C3-C3"].Key, children[2].Entity.Key); + }); + } + + [Test] + public async Task ChildUserAccessEntities_RootContainerAsStartNode_YieldsMixedChildrenContainersAndElements_AsAllowed() + { + // Root container "C1" is used as start node - its children include both child containers and elements + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C1"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C1"].Key, + 0, + 100, + BySortOrder, + out var totalItems) + .ToArray(); + + // C1 has 10 child containers (C1-C1 through C1-C10) and 2 elements (C1-E1, C1-E2) + Assert.AreEqual(12, totalItems); + Assert.AreEqual(12, children.Length); + Assert.Multiple(() => + { + // All children should be allowed because "C1" is a start node + Assert.IsTrue(children.All(c => c.HasAccess)); + + // Verify we have both containers and elements in the results + var containerChildren = children.Where(c => c.Entity.Name!.Contains("-C")).ToArray(); + var elementChildren = children.Where(c => c.Entity.Name!.Contains("-E")).ToArray(); + Assert.AreEqual(10, containerChildren.Length); + Assert.AreEqual(2, elementChildren.Length); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.RootUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.RootUserAccessEntities.cs new file mode 100644 index 000000000000..60c353ef7682 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.RootUserAccessEntities.cs @@ -0,0 +1,58 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceElementTests +{ + [Test] + public async Task RootUserAccessEntities_FirstAndLastRootContainer_YieldsBoth_AsAllowed() + { + // Root containers "C1" and "C5" are used as start nodes + var elementStartNodeIds = await CreateUserAndGetStartNodeIds(ItemsByName["C1"].Id, ItemsByName["C5"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodeIds) + .ToArray(); + + // expected total is 2, because only two items at root ("C1" and "C5") are allowed + Assert.AreEqual(2, roots.Length); + Assert.Multiple(() => + { + // first and last root containers are the ones allowed + Assert.AreEqual(ItemsByName["C1"].Key, roots[0].Entity.Key); + Assert.AreEqual(ItemsByName["C5"].Key, roots[1].Entity.Key); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(roots[0].HasAccess); + Assert.IsTrue(roots[1].HasAccess); + }); + } + + [Test] + public async Task RootUserAccessEntities_ChildContainersAsStartNode_YieldsChildRoots_AsNotAllowed() + { + // Child containers "C1-C3", "C3-C3", "C5-C3" are used as start nodes + var elementStartNodeIds = await CreateUserAndGetStartNodeIds(ItemsByName["C1-C3"].Id, ItemsByName["C3-C3"].Id, ItemsByName["C5-C3"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodeIds) + .ToArray(); + + Assert.AreEqual(3, roots.Length); + Assert.Multiple(() => + { + // the three start nodes are the children of the "C1", "C3" and "C5" roots, respectively, so these are expected as roots + Assert.AreEqual(ItemsByName["C1"].Key, roots[0].Entity.Key); + Assert.AreEqual(ItemsByName["C3"].Key, roots[1].Entity.Key); + Assert.AreEqual(ItemsByName["C5"].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)); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.SiblingUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.SiblingUserAccessEntities.cs new file mode 100644 index 000000000000..be3240263c88 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.SiblingUserAccessEntities.cs @@ -0,0 +1,202 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceElementTests +{ + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParent_YieldsAllContainerSiblings_AsAllowed() + { + // Root container "C1" is used as start node + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C1"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C1-C5"].Key, + 2, + 2, + BySortOrder, + out long totalBefore, + out long totalAfter) + .ToArray(); + + // C1 has 10 child containers (C1-C1 through C1-C10) and 2 elements (C1-E1, C1-E2) = 12 total + // Target is C1-C5, requesting 2 before and 2 after + // Before C1-C5: C1-C1, C1-C2, C1-C3, C1-C4 = 4 items, returning 2, so totalBefore = 4 - 2 = 2 + // After C1-C5: C1-C6 through C1-C10 (5) + C1-E1, C1-E2 (2) = 7 items, returning 2, so totalAfter = 7 - 2 = 5 + Assert.AreEqual(2, totalBefore); + Assert.AreEqual(5, totalAfter); + Assert.AreEqual(5, siblings.Length); + Assert.Multiple(() => + { + // Siblings returned: C1-C3, C1-C4, C1-C5, C1-C6, C1-C7 + Assert.AreEqual(ItemsByName["C1-C3"].Key, siblings[0].Entity.Key); + Assert.AreEqual(ItemsByName["C1-C4"].Key, siblings[1].Entity.Key); + Assert.AreEqual(ItemsByName["C1-C5"].Key, siblings[2].Entity.Key); + Assert.AreEqual(ItemsByName["C1-C6"].Key, siblings[3].Entity.Key); + Assert.AreEqual(ItemsByName["C1-C7"].Key, siblings[4].Entity.Key); + Assert.IsTrue(siblings.All(s => s.HasAccess)); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget_YieldsOnlyTarget_AsAllowed() + { + // See notes on ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild. + + // Root container "C1" and child container "C1-C5" are used as start nodes + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C1"].Id, ItemsByName["C1-C5"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C1-C5"].Key, + 2, + 2, + BySortOrder, + out long totalBefore, + out long totalAfter) + .ToArray(); + + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); + Assert.AreEqual(1, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(ItemsByName["C1-C5"].Key, siblings[0].Entity.Key); + Assert.IsTrue(siblings[0].HasAccess); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarget_AsAllowed() + { + // Child container "C1-C5" is used as start node + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C1-C5"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C1-C5"].Key, + 2, + 2, + BySortOrder, + out long totalBefore, + out long totalAfter) + .ToArray(); + + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); + Assert.AreEqual(1, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(ItemsByName["C1-C5"].Key, siblings[0].Entity.Key); + Assert.IsTrue(siblings[0].HasAccess); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed() + { + // Multiple child containers are used as start nodes + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C1-C3"].Id, ItemsByName["C1-C5"].Id, ItemsByName["C1-C7"].Id, ItemsByName["C1-C10"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C1-C5"].Key, + 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(ItemsByName["C1-C3"].Key, siblings[0].Entity.Key); + Assert.IsTrue(siblings[0].HasAccess); + Assert.AreEqual(ItemsByName["C1-C5"].Key, siblings[1].Entity.Key); + Assert.IsTrue(siblings[1].HasAccess); + Assert.AreEqual(ItemsByName["C1-C7"].Key, siblings[2].Entity.Key); + Assert.IsTrue(siblings[2].HasAccess); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTargetDescendant_YieldsTarget_AsNotAllowed() + { + // Child container "C1-C5" is used as start node (descendant of root container "C1") + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C1-C5"].Id); + + // Query for siblings of root container "C1" - the parent of our start node + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.ElementContainer, + elementStartNodePaths, + ItemsByName["C1"].Key, + 2, + 2, + BySortOrder, + out long totalBefore, + out long totalAfter) + .ToArray(); + + // User can see "C1" (to navigate to their start node "C1-C5"), but doesn't have direct access + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); + Assert.AreEqual(1, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(ItemsByName["C1"].Key, siblings[0].Entity.Key); + Assert.IsFalse(siblings[0].HasAccess); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParent_YieldsMixedSiblings_AsAllowed() + { + // Root container "C1" is used as start node - its children include both containers and elements + var elementStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["C1"].Id); + + // Query for siblings of element "C1-E1" (which has container siblings too) + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + [UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], + elementStartNodePaths, + ItemsByName["C1-E1"].Key, + 2, + 2, + BySortOrder, + out long totalBefore, + out long totalAfter) + .ToArray(); + + // C1-E1 is after all child containers (C1-C1 through C1-C10), so it has 10 items before it + // and C1-E2 after it + // Before C1-E1: C1-C1 through C1-C10 = 10 items, returning 2, so totalBefore = 10 - 2 = 8 + // After C1-E1: C1-E2 = 1 item, returning 1 (not 2 since only 1 exists), so totalAfter = 0 + Assert.AreEqual(8, totalBefore); + Assert.AreEqual(0, totalAfter); + Assert.AreEqual(4, siblings.Length); // 2 before + target + 1 after + Assert.Multiple(() => + { + // Siblings returned: C1-C9, C1-C10, C1-E1, C1-E2 + Assert.AreEqual(ItemsByName["C1-C9"].Key, siblings[0].Entity.Key); + Assert.AreEqual(ItemsByName["C1-C10"].Key, siblings[1].Entity.Key); + Assert.AreEqual(ItemsByName["C1-E1"].Key, siblings[2].Entity.Key); + Assert.AreEqual(ItemsByName["C1-E2"].Key, siblings[3].Entity.Key); + Assert.IsTrue(siblings.All(s => s.HasAccess)); + }); + } +} \ No newline at end of file diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.cs new file mode 100644 index 000000000000..14882c340e0e --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceElementTests.cs @@ -0,0 +1,114 @@ +using NUnit.Framework; +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; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +/// +/// Element tests use a folder/container structure with elements mixed at each level: +/// - Level 1 (Root): Containers "C1"-"C5" + Elements "E1"-"E3" +/// - Level 2: Child containers "C1-C1" through "C1-C10" + Elements "C1-E1", "C1-E2" under each root container +/// - Level 3: Elements "C1-C1-E1" through "C1-C1-E5" under each child container +/// +[TestFixture] +public partial class UserStartNodeEntitiesServiceElementTests : UserStartNodeEntitiesServiceTestsBase +{ + private IElementService ElementService => GetRequiredService(); + + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + protected override UmbracoObjectTypes ObjectType => UmbracoObjectTypes.Element; + + protected override string SectionAlias => Constants.Applications.Library; + + protected override async Task CreateContentTypeAndHierarchy() + { + // Create the element content type + var contentType = new ContentTypeBuilder() + .WithAlias("theElementType") + .WithIsElement(true) + .Build(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create hierarchy with mixed containers and elements at each level + foreach (var rootNumber in Enumerable.Range(1, 5)) + { + // Level 1: Root containers (folders) + var rootContainerResult = await ElementContainerService.CreateAsync( + null, + $"C{rootNumber}", + null, // parent at root + Constants.Security.SuperUserKey); + Assert.IsTrue(rootContainerResult.Success); + Assert.NotNull(rootContainerResult.Result); + var rootContainer = rootContainerResult.Result; + ItemsByName[rootContainer.Name!] = (rootContainer.Id, rootContainer.Key); + + foreach (var childNumber in Enumerable.Range(1, 10)) + { + // Level 2: Child containers (folders) + var childContainerResult = await ElementContainerService.CreateAsync( + null, + $"C{rootNumber}-C{childNumber}", + rootContainer.Key, + Constants.Security.SuperUserKey); + Assert.IsTrue(childContainerResult.Success); + Assert.NotNull(childContainerResult.Result); + var childContainer = childContainerResult.Result; + ItemsByName[childContainer.Name!] = (childContainer.Id, childContainer.Key); + + foreach (var grandChildNumber in Enumerable.Range(1, 5)) + { + // Level 3: Elements (leaf nodes) + var element = ElementService.Create($"C{rootNumber}-C{childNumber}-E{grandChildNumber}", contentType.Alias); + element.ParentId = childContainer.Id; + var saveElementResult = ElementService.Save([element]); + Assert.IsTrue(saveElementResult.Success); + ItemsByName[element.Name!] = (element.Id, element.Key); + } + } + + // Level 2: Elements alongside child containers (mixed level) + foreach (var elementNumber in Enumerable.Range(1, 2)) + { + var element = ElementService.Create($"C{rootNumber}-E{elementNumber}", contentType.Alias); + element.ParentId = rootContainer.Id; + var saveElementResult = ElementService.Save([element]); + Assert.IsTrue(saveElementResult.Success); + ItemsByName[element.Name!] = (element.Id, element.Key); + } + } + + // Level 1: Root elements alongside root containers (mixed level) + foreach (var elementNumber in Enumerable.Range(1, 3)) + { + var element = ElementService.Create($"E{elementNumber}", contentType.Alias); + var saveElementResult = ElementService.Save([element]); + Assert.IsTrue(saveElementResult.Success); + ItemsByName[element.Name!] = (element.Id, element.Key); + } + } + + protected override void ClearUserGroupStartNode(IUserGroup userGroup) + => userGroup.StartElementId = null; + + protected override Core.Models.Membership.User BuildUserWithStartNodes(int[] startNodeIds) + => new UserBuilder() + .WithName(Guid.NewGuid().ToString("N")) + .WithStartElementIds(startNodeIds) + .Build(); + + protected override string[]? GetStartNodePaths(Cms.Core.Models.Membership.User user) + => user.GetElementStartNodePaths(EntityService, AppCaches.NoCache); + + protected override int[]? CalculateStartNodeIds(Cms.Core.Models.Membership.User user) + => user.CalculateElementStartNodeIds(EntityService, AppCaches.NoCache); +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs index a6ab1ff13110..f3ccb9cd5fa1 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs @@ -8,7 +8,7 @@ public partial class UserStartNodeEntitiesServiceMediaTests [Test] public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed() { - var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1"].Id, _mediaByName["5"].Id); + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(ItemsByName["1"].Id, ItemsByName["5"].Id); var roots = UserStartNodeEntitiesService .RootUserAccessEntities( @@ -21,8 +21,8 @@ public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed() 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); + Assert.AreEqual(ItemsByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(ItemsByName["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 @@ -38,7 +38,7 @@ public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed() [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 contentStartNodeIds = await CreateUserAndGetStartNodeIds(ItemsByName["1-3"].Id, ItemsByName["3-3"].Id, ItemsByName["5-3"].Id); var roots = UserStartNodeEntitiesService .RootUserAccessEntities( @@ -50,9 +50,9 @@ public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_As 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); + Assert.AreEqual(ItemsByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(ItemsByName["3"].Key, roots[1].Entity.Key); + Assert.AreEqual(ItemsByName["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)); @@ -62,7 +62,7 @@ public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_As [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 contentStartNodeIds = await CreateUserAndGetStartNodeIds(ItemsByName["1-2-3"].Id, ItemsByName["2-3-4"].Id, ItemsByName["3-4-5"].Id); var roots = UserStartNodeEntitiesService .RootUserAccessEntities( @@ -74,9 +74,9 @@ public async Task RootUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchi 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); + Assert.AreEqual(ItemsByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(ItemsByName["2"].Key, roots[1].Entity.Key); + Assert.AreEqual(ItemsByName["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.SiblingUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs index d545416eb1ea..cac7cb7a4b5a 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs @@ -8,13 +8,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests [Test] public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParent_YieldsAll_AsAllowed() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["1-5"].Key, + ItemsByName["1-5"].Key, 2, 2, BySortOrder, @@ -29,7 +29,7 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParent_YieldsAl { for (int i = 0; i < 4; i++) { - Assert.AreEqual(_mediaByName[$"1-{i + 3}"].Key, siblings[i].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-{i + 3}"].Key, siblings[i].Entity.Key); Assert.IsTrue(siblings[i].HasAccess); } }); @@ -40,13 +40,13 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget { // See notes on ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild. - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1"].Id, _mediaByName["1-5"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1"].Id, ItemsByName["1-5"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Media, contentStartNodePaths, - _mediaByName["1-5"].Key, + ItemsByName["1-5"].Key, 2, 2, BySortOrder, @@ -59,7 +59,7 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { - Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-5"].Key, siblings[0].Entity.Key); Assert.IsTrue(siblings[0].HasAccess); }); } @@ -67,13 +67,13 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget [Test] public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarget_AsAllowed() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-5"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Media, contentStartNodePaths, - _mediaByName["1-5"].Key, + ItemsByName["1-5"].Key, 2, 2, BySortOrder, @@ -86,7 +86,7 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarg Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { - Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-5"].Key, siblings[0].Entity.Key); Assert.IsTrue(siblings[0].HasAccess); }); } @@ -94,13 +94,13 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarg [Test] public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-3"].Id, _mediaByName["1-5"].Id, _mediaByName["1-7"].Id, _mediaByName["1-10"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-3"].Id, ItemsByName["1-5"].Id, ItemsByName["1-7"].Id, ItemsByName["1-10"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["1-5"].Key, + ItemsByName["1-5"].Key, 1, 1, BySortOrder, @@ -113,11 +113,11 @@ public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_Y Assert.AreEqual(3, siblings.Length); Assert.Multiple(() => { - Assert.AreEqual(_mediaByName[$"1-3"].Key, siblings[0].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-3"].Key, siblings[0].Entity.Key); Assert.IsTrue(siblings[0].HasAccess); - Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[1].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-5"].Key, siblings[1].Entity.Key); Assert.IsTrue(siblings[1].HasAccess); - Assert.AreEqual(_mediaByName[$"1-7"].Key, siblings[2].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-7"].Key, siblings[2].Entity.Key); Assert.IsTrue(siblings[2].HasAccess); }); } @@ -125,13 +125,13 @@ public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_Y [Test] public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetGrandchild_YieldsTarget_AsNotAllowed() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5-1"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-5-1"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["1-5"].Key, + ItemsByName["1-5"].Key, 2, 2, BySortOrder, @@ -144,7 +144,7 @@ public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetGrandchild_Yi Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { - Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-5"].Key, siblings[0].Entity.Key); Assert.IsFalse(siblings[0].HasAccess); }); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs index 4df5ecbab71c..c68ed770b30c 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs @@ -8,13 +8,13 @@ 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 mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-1"].Id, ItemsByName["1-10"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["1"].Key, + ItemsByName["1"].Key, 0, 3, BySortOrder, @@ -27,8 +27,8 @@ public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFi 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); + Assert.AreEqual(ItemsByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["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" @@ -44,14 +44,14 @@ public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFi [Test] public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInScope() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id, _mediaByName["2-10"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-5"].Id, ItemsByName["2-10"].Id); Assert.AreEqual(2, mediaStartNodePaths.Length); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["2"].Key, + ItemsByName["2"].Key, 0, 10, BySortOrder, @@ -63,7 +63,7 @@ public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInSc 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.AreEqual(ItemsByName["2-10"].Key, children[0].Entity.Key); Assert.IsTrue(children[0].HasAccess); }); } @@ -71,14 +71,14 @@ public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInSc [Test] public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id, _mediaByName["2-10"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-5"].Id, ItemsByName["2-10"].Id); Assert.AreEqual(2, mediaStartNodePaths.Length); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["3"].Key, + ItemsByName["3"].Key, 0, 10, BySortOrder, @@ -93,17 +93,17 @@ public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing() 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); + ItemsByName["1-1"].Id, + ItemsByName["1-3"].Id, + ItemsByName["1-5"].Id, + ItemsByName["1-7"].Id, + ItemsByName["1-9"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["1"].Key, + ItemsByName["1"].Key, 0, 2, BySortOrder, @@ -115,8 +115,8 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat 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.AreEqual(ItemsByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["1-3"].Key, children[1].Entity.Key); Assert.IsTrue(children[0].HasAccess); Assert.IsTrue(children[1].HasAccess); }); @@ -126,7 +126,7 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["1"].Key, + ItemsByName["1"].Key, 2, 2, BySortOrder, @@ -138,8 +138,8 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat 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.AreEqual(ItemsByName["1-5"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["1-7"].Key, children[1].Entity.Key); Assert.IsTrue(children[0].HasAccess); Assert.IsTrue(children[1].HasAccess); }); @@ -149,7 +149,7 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["1"].Key, + ItemsByName["1"].Key, 4, 2, BySortOrder, @@ -161,7 +161,7 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat Assert.AreEqual(1, children.Length); Assert.Multiple(() => { - Assert.AreEqual(_mediaByName["1-9"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["1-9"].Key, children[0].Entity.Key); Assert.IsTrue(children[0].HasAccess); }); } @@ -169,13 +169,13 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat [Test] public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchildren_AsAllowed() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["3-3"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["3-3"].Key, + ItemsByName["3-3"].Key, 0, 100, BySortOrder, @@ -190,7 +190,7 @@ public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchil foreach (var childNumber in Enumerable.Range(1, 5)) { var child = children[childNumber - 1]; - Assert.AreEqual(_mediaByName[$"3-3-{childNumber}"].Key, child.Entity.Key); + Assert.AreEqual(ItemsByName[$"3-3-{childNumber}"].Key, child.Entity.Key); Assert.IsTrue(child.HasAccess); } }); @@ -199,13 +199,13 @@ public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchil [Test] public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildren_AsAllowed() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3-3"].Id, _mediaByName["3-3-4"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["3-3-3"].Id, ItemsByName["3-3-4"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["3-3"].Key, + ItemsByName["3-3"].Key, 0, 3, BySortOrder, @@ -217,8 +217,8 @@ public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandch 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); + Assert.AreEqual(ItemsByName["3-3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["3-3-4"].Key, children[1].Entity.Key); // both are allowed (they are the actual start nodes) Assert.IsTrue(children[0].HasAccess); @@ -229,13 +229,13 @@ public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandch [Test] public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsChildren_AsNotAllowed() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3-3"].Id, _mediaByName["3-5-3"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["3-3-3"].Id, ItemsByName["3-5-3"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["3"].Key, + ItemsByName["3"].Key, 0, 3, BySortOrder, @@ -247,8 +247,8 @@ public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsChildre 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); + Assert.AreEqual(ItemsByName["3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["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); @@ -264,14 +264,14 @@ public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOn // 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); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["3-3"].Id, ItemsByName["3-3-1"].Id, ItemsByName["3-3-5"].Id); Assert.AreEqual(2, mediaStartNodePaths.Length); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["3"].Key, + ItemsByName["3"].Key, 0, 10, BySortOrder, @@ -281,7 +281,7 @@ public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOn Assert.AreEqual(1, children.Length); Assert.Multiple(() => { - Assert.AreEqual(_mediaByName["3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["3-3"].Key, children[0].Entity.Key); Assert.IsFalse(children[0].HasAccess); }); @@ -289,7 +289,7 @@ public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOn .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["3-3"].Key, + ItemsByName["3-3"].Key, 0, 10, BySortOrder, @@ -301,8 +301,8 @@ public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOn 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.AreEqual(ItemsByName["3-3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["3-3-5"].Key, children[1].Entity.Key); Assert.IsTrue(children[0].HasAccess); Assert.IsTrue(children[1].HasAccess); @@ -312,13 +312,13 @@ public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOn [Test] public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectResultOrder() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id, _mediaByName["3-2"].Id, _mediaByName["3-1"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["3-3"].Id, ItemsByName["3-2"].Id, ItemsByName["3-1"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, - _mediaByName["3"].Key, + ItemsByName["3"].Key, 0, 10, BySortOrder, @@ -328,9 +328,9 @@ public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectRes 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); + Assert.AreEqual(ItemsByName["3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["3-2"].Key, children[1].Entity.Key); + Assert.AreEqual(ItemsByName["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 index 241dd1b604ff..c34730344a6f 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.cs @@ -1,6 +1,4 @@ -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; @@ -8,46 +6,22 @@ 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 +public partial class UserStartNodeEntitiesServiceMediaTests : UserStartNodeEntitiesServiceTestsBase { - 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(); + protected override UmbracoObjectTypes ObjectType => UmbracoObjectTypes.Media; - private IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService(); - - protected readonly Ordering BySortOrder = Ordering.By("sortOrder"); - - protected override void ConfigureTestServices(IServiceCollection services) - { - base.ConfigureTestServices(services); - services.AddTransient(); - } + protected override string SectionAlias => "media"; - [SetUp] - public async Task SetUpTestAsync() + protected override async Task CreateContentTypeAndHierarchy() { - if (_mediaByName.Any()) - { - return; - } - var mediaType = new MediaTypeBuilder() .WithAlias("theMediaType") .Build(); @@ -63,7 +37,7 @@ public async Task SetUpTestAsync() .WithName($"{rootNumber}") .Build(); MediaService.Save(root); - _mediaByName[root.Name!] = root; + ItemsByName[root.Name!] = (root.Id, root.Key); foreach (var childNumber in Enumerable.Range(1, 10)) { @@ -73,7 +47,7 @@ public async Task SetUpTestAsync() .Build(); child.SetParent(root); MediaService.Save(child); - _mediaByName[child.Name!] = child; + ItemsByName[child.Name!] = (child.Id, child.Key); foreach (var grandChildNumber in Enumerable.Range(1, 5)) { @@ -83,52 +57,24 @@ public async Task SetUpTestAsync() .Build(); grandchild.SetParent(child); MediaService.Save(grandchild); - _mediaByName[grandchild.Name!] = grandchild; + ItemsByName[grandchild.Name!] = (grandchild.Id, grandchild.Key); } } } - - _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); + protected override void ClearUserGroupStartNode(IUserGroup userGroup) + => userGroup.StartMediaId = null; - var mediaStartNodeIds = user.CalculateMediaStartNodeIds(EntityService, AppCaches.NoCache); - Assert.IsNotNull(mediaStartNodeIds); - - return mediaStartNodeIds; - } - - private async Task CreateUser(int[] startNodeIds) - { - var user = new UserBuilder() + protected override Core.Models.Membership.User BuildUserWithStartNodes(int[] startNodeIds) + => 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); + protected override string[]? GetStartNodePaths(Core.Models.Membership.User user) + => user.GetMediaStartNodePaths(EntityService, AppCaches.NoCache); - Assert.IsTrue(attempt.Success); - return user; - } + protected override int[]? CalculateStartNodeIds(Core.Models.Membership.User user) + => user.CalculateMediaStartNodeIds(EntityService, AppCaches.NoCache); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs index c64b61810fc0..c25efea18e81 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs @@ -8,13 +8,13 @@ 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 contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-1"].Id, ItemsByName["1-10"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["1"].Key, + ItemsByName["1"].Key, 0, 3, BySortOrder, @@ -27,8 +27,8 @@ public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFi 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); + Assert.AreEqual(ItemsByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["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" @@ -44,14 +44,14 @@ public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFi [Test] public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInScope() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id, _contentByName["2-10"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-5"].Id, ItemsByName["2-10"].Id); Assert.AreEqual(2, contentStartNodePaths.Length); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["2"].Key, + ItemsByName["2"].Key, 0, 10, BySortOrder, @@ -63,7 +63,7 @@ public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInSc 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.AreEqual(ItemsByName["2-10"].Key, children[0].Entity.Key); Assert.IsTrue(children[0].HasAccess); }); } @@ -71,14 +71,14 @@ public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInSc [Test] public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id, _contentByName["2-10"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-5"].Id, ItemsByName["2-10"].Id); Assert.AreEqual(2, contentStartNodePaths.Length); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["3"].Key, + ItemsByName["3"].Key, 0, 10, BySortOrder, @@ -93,17 +93,17 @@ public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing() 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); + ItemsByName["1-1"].Id, + ItemsByName["1-3"].Id, + ItemsByName["1-5"].Id, + ItemsByName["1-7"].Id, + ItemsByName["1-9"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["1"].Key, + ItemsByName["1"].Key, 0, 2, BySortOrder, @@ -115,8 +115,8 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat 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.AreEqual(ItemsByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["1-3"].Key, children[1].Entity.Key); Assert.IsTrue(children[0].HasAccess); Assert.IsTrue(children[1].HasAccess); }); @@ -126,7 +126,7 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["1"].Key, + ItemsByName["1"].Key, 2, 2, BySortOrder, @@ -138,8 +138,8 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat 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.AreEqual(ItemsByName["1-5"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["1-7"].Key, children[1].Entity.Key); Assert.IsTrue(children[0].HasAccess); Assert.IsTrue(children[1].HasAccess); }); @@ -149,7 +149,7 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["1"].Key, + ItemsByName["1"].Key, 4, 2, BySortOrder, @@ -161,7 +161,7 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat Assert.AreEqual(1, children.Length); Assert.Multiple(() => { - Assert.AreEqual(_contentByName["1-9"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["1-9"].Key, children[0].Entity.Key); Assert.IsTrue(children[0].HasAccess); }); } @@ -169,13 +169,13 @@ public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginat [Test] public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchildren_AsAllowed() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["3-3"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["3-3"].Key, + ItemsByName["3-3"].Key, 0, 100, BySortOrder, @@ -190,7 +190,7 @@ public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchil foreach (var childNumber in Enumerable.Range(1, 5)) { var child = children[childNumber - 1]; - Assert.AreEqual(_contentByName[$"3-3-{childNumber}"].Key, child.Entity.Key); + Assert.AreEqual(ItemsByName[$"3-3-{childNumber}"].Key, child.Entity.Key); Assert.IsTrue(child.HasAccess); } }); @@ -199,13 +199,13 @@ public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchil [Test] public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildren_AsAllowed() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3-3"].Id, _contentByName["3-3-4"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["3-3-3"].Id, ItemsByName["3-3-4"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["3-3"].Key, + ItemsByName["3-3"].Key, 0, 3, BySortOrder, @@ -217,8 +217,8 @@ public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandch 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); + Assert.AreEqual(ItemsByName["3-3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["3-3-4"].Key, children[1].Entity.Key); // both are allowed (they are the actual start nodes) Assert.IsTrue(children[0].HasAccess); @@ -229,13 +229,13 @@ public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandch [Test] public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsChildren_AsNotAllowed() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3-3"].Id, _contentByName["3-5-3"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["3-3-3"].Id, ItemsByName["3-5-3"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["3"].Key, + ItemsByName["3"].Key, 0, 3, BySortOrder, @@ -247,8 +247,8 @@ public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsChildre 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); + Assert.AreEqual(ItemsByName["3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["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); @@ -264,14 +264,14 @@ public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOn // 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); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["3-3"].Id, ItemsByName["3-3-1"].Id, ItemsByName["3-3-5"].Id); Assert.AreEqual(2, contentStartNodePaths.Length); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["3"].Key, + ItemsByName["3"].Key, 0, 10, BySortOrder, @@ -281,7 +281,7 @@ public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOn Assert.AreEqual(1, children.Length); Assert.Multiple(() => { - Assert.AreEqual(_contentByName["3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["3-3"].Key, children[0].Entity.Key); Assert.IsFalse(children[0].HasAccess); }); @@ -289,7 +289,7 @@ public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOn .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["3-3"].Key, + ItemsByName["3-3"].Key, 0, 10, BySortOrder, @@ -301,8 +301,8 @@ public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOn 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.AreEqual(ItemsByName["3-3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["3-3-5"].Key, children[1].Entity.Key); Assert.IsTrue(children[0].HasAccess); Assert.IsTrue(children[1].HasAccess); @@ -312,13 +312,13 @@ public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOn [Test] public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectResultOrder() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id, _contentByName["3-2"].Id, _contentByName["3-1"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["3-3"].Id, ItemsByName["3-2"].Id, ItemsByName["3-1"].Id); var children = UserStartNodeEntitiesService .ChildUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["3"].Key, + ItemsByName["3"].Key, 0, 10, BySortOrder, @@ -328,9 +328,9 @@ public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectRes 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); + Assert.AreEqual(ItemsByName["3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(ItemsByName["3-2"].Key, children[1].Entity.Key); + Assert.AreEqual(ItemsByName["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 index c73ac2778b8c..b1a5b236c1ab 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs @@ -8,7 +8,7 @@ public partial class UserStartNodeEntitiesServiceTests [Test] public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed() { - var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_contentByName["1"].Id, _contentByName["5"].Id); + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(ItemsByName["1"].Id, ItemsByName["5"].Id); var roots = UserStartNodeEntitiesService .RootUserAccessEntities( @@ -21,8 +21,8 @@ public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed() 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); + Assert.AreEqual(ItemsByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(ItemsByName["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 @@ -38,7 +38,7 @@ public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed() [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 contentStartNodeIds = await CreateUserAndGetStartNodeIds(ItemsByName["1-3"].Id, ItemsByName["3-3"].Id, ItemsByName["5-3"].Id); var roots = UserStartNodeEntitiesService .RootUserAccessEntities( @@ -50,9 +50,9 @@ public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_As 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); + Assert.AreEqual(ItemsByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(ItemsByName["3"].Key, roots[1].Entity.Key); + Assert.AreEqual(ItemsByName["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)); @@ -62,7 +62,7 @@ public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_As [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 contentStartNodeIds = await CreateUserAndGetStartNodeIds(ItemsByName["1-2-3"].Id, ItemsByName["2-3-4"].Id, ItemsByName["3-4-5"].Id); var roots = UserStartNodeEntitiesService .RootUserAccessEntities( @@ -74,9 +74,9 @@ public async Task RootUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchi 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); + Assert.AreEqual(ItemsByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(ItemsByName["2"].Key, roots[1].Entity.Key); + Assert.AreEqual(ItemsByName["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.SiblingUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs index 9e19fa9800d8..164509167ed9 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs @@ -8,13 +8,13 @@ public partial class UserStartNodeEntitiesServiceTests [Test] public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParent_YieldsAll_AsAllowed() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["1-5"].Key, + ItemsByName["1-5"].Key, 2, 2, BySortOrder, @@ -29,7 +29,7 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParent_YieldsAl { for (int i = 0; i < 4; i++) { - Assert.AreEqual(_contentByName[$"1-{i + 3}"].Key, siblings[i].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-{i + 3}"].Key, siblings[i].Entity.Key); Assert.IsTrue(siblings[i].HasAccess); } }); @@ -40,13 +40,13 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget { // See notes on ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild. - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1"].Id, _contentByName["1-5"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1"].Id, ItemsByName["1-5"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["1-5"].Key, + ItemsByName["1-5"].Key, 2, 2, BySortOrder, @@ -59,7 +59,7 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { - Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-5"].Key, siblings[0].Entity.Key); Assert.IsTrue(siblings[0].HasAccess); }); } @@ -67,13 +67,13 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget [Test] public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarget_AsAllowed() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-5"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["1-5"].Key, + ItemsByName["1-5"].Key, 2, 2, BySortOrder, @@ -86,7 +86,7 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarg Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { - Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-5"].Key, siblings[0].Entity.Key); Assert.IsTrue(siblings[0].HasAccess); }); } @@ -94,13 +94,13 @@ public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarg [Test] public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-3"].Id, _contentByName["1-5"].Id, _contentByName["1-7"].Id, _contentByName["1-10"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-3"].Id, ItemsByName["1-5"].Id, ItemsByName["1-7"].Id, ItemsByName["1-10"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["1-5"].Key, + ItemsByName["1-5"].Key, 1, 1, BySortOrder, @@ -114,11 +114,11 @@ public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_Y Assert.Multiple(() => { - Assert.AreEqual(_contentByName[$"1-3"].Key, siblings[0].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-3"].Key, siblings[0].Entity.Key); Assert.IsTrue(siblings[0].HasAccess); - Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[1].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-5"].Key, siblings[1].Entity.Key); Assert.IsTrue(siblings[1].HasAccess); - Assert.AreEqual(_contentByName[$"1-7"].Key, siblings[2].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-7"].Key, siblings[2].Entity.Key); Assert.IsTrue(siblings[2].HasAccess); }); } @@ -126,13 +126,13 @@ public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_Y [Test] public async Task SiblingUserAccessEntities_WithStartNodesOfTargetChild_YieldsTarget_AsNotAllowed() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5-1"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(ItemsByName["1-5-1"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, - _contentByName["1-5"].Key, + ItemsByName["1-5"].Key, 2, 2, BySortOrder, @@ -145,7 +145,7 @@ public async Task SiblingUserAccessEntities_WithStartNodesOfTargetChild_YieldsTa Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { - Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.AreEqual(ItemsByName[$"1-5"].Key, siblings[0].Entity.Key); Assert.IsFalse(siblings[0].HasAccess); }); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs index e7ad544af6c7..7dd9c70c2e83 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs @@ -1,6 +1,4 @@ -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; @@ -8,46 +6,22 @@ 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 +public partial class UserStartNodeEntitiesServiceTests : UserStartNodeEntitiesServiceTestsBase { - 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(); + protected override UmbracoObjectTypes ObjectType => UmbracoObjectTypes.Document; - private IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService(); - - protected static readonly Ordering BySortOrder = Ordering.By("sortOrder"); - - protected override void ConfigureTestServices(IServiceCollection services) - { - base.ConfigureTestServices(services); - services.AddTransient(); - } + protected override string SectionAlias => "content"; - [SetUp] - public async Task SetUpTestAsync() + protected override async Task CreateContentTypeAndHierarchy() { - if (_contentByName.Any()) - { - return; - } - var contentType = new ContentTypeBuilder() .WithAlias("theContentType") .Build(); @@ -63,7 +37,7 @@ public async Task SetUpTestAsync() .WithName($"{rootNumber}") .Build(); ContentService.Save(root); - _contentByName[root.Name!] = root; + ItemsByName[root.Name!] = (root.Id, root.Key); foreach (var childNumber in Enumerable.Range(1, 10)) { @@ -73,7 +47,7 @@ public async Task SetUpTestAsync() .WithName($"{rootNumber}-{childNumber}") .Build(); ContentService.Save(child); - _contentByName[child.Name!] = child; + ItemsByName[child.Name!] = (child.Id, child.Key); foreach (var grandChildNumber in Enumerable.Range(1, 5)) { @@ -83,52 +57,24 @@ public async Task SetUpTestAsync() .WithName($"{rootNumber}-{childNumber}-{grandChildNumber}") .Build(); ContentService.Save(grandchild); - _contentByName[grandchild.Name!] = grandchild; + ItemsByName[grandchild.Name!] = (grandchild.Id, grandchild.Key); } } } - - _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); + protected override void ClearUserGroupStartNode(IUserGroup userGroup) + => userGroup.StartContentId = null; - var contentStartNodeIds = user.CalculateContentStartNodeIds(EntityService, AppCaches.NoCache); - Assert.IsNotNull(contentStartNodeIds); - - return contentStartNodeIds; - } - - private async Task CreateUser(int[] startNodeIds) - { - var user = new UserBuilder() + protected override Cms.Core.Models.Membership.User BuildUserWithStartNodes(int[] startNodeIds) + => 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); + protected override string[]? GetStartNodePaths(Cms.Core.Models.Membership.User user) + => user.GetContentStartNodePaths(EntityService, AppCaches.NoCache); - Assert.IsTrue(attempt.Success); - return user; - } + protected override int[]? CalculateStartNodeIds(Cms.Core.Models.Membership.User user) + => user.CalculateContentStartNodeIds(EntityService, AppCaches.NoCache); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTestsBase.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTestsBase.cs new file mode 100644 index 000000000000..5f6422b79a04 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTestsBase.cs @@ -0,0 +1,148 @@ +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.Entities; +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; + +/// +/// Abstract base class for UserStartNodeEntitiesService tests. +/// Provides common setup, services, and helper methods for testing start node access +/// across different content types (Document, Media, Element). +/// +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] +public abstract class UserStartNodeEntitiesServiceTestsBase : UmbracoIntegrationTest +{ + /// + /// All items by name, storing just the Id and Key needed for tests. + /// + protected Dictionary ItemsByName { get; } = new(); + + protected IUserGroup UserGroup { get; set; } = null!; + + protected IUserGroupService UserGroupService => GetRequiredService(); + + protected IUserService UserService => GetRequiredService(); + + protected IEntityService EntityService => GetRequiredService(); + + protected IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService(); + + protected static readonly Ordering BySortOrder = Ordering.By("sortOrder"); + + /// + /// Gets the UmbracoObjectType for the content type being tested. + /// + protected abstract UmbracoObjectTypes ObjectType { get; } + + /// + /// Gets the section alias for the user group (e.g., "content", "media", "element"). + /// + protected abstract string SectionAlias { get; } + + protected override void ConfigureTestServices(IServiceCollection services) + { + base.ConfigureTestServices(services); + services.AddTransient(); + } + + [SetUp] + public async Task SetUpTestAsync() + { + if (ItemsByName.Any()) + { + return; + } + + await CreateContentTypeAndHierarchy(); + await CreateUserGroup(); + } + + /// + /// Creates the content type and a hierarchy of items: + /// 5 roots, each with 10 children, each with 5 grandchildren (300 total items). + /// Items are named by their position: "1", "1-1", "1-1-1", etc. + /// + protected abstract Task CreateContentTypeAndHierarchy(); + + /// + /// Creates the user group for testing. + /// + protected virtual async Task CreateUserGroup() + { + UserGroup = new UserGroupBuilder() + .WithAlias(Guid.NewGuid().ToString("N")) + .WithAllowedSections([SectionAlias]) + .Build(); + + ClearUserGroupStartNode(UserGroup); + await UserGroupService.CreateAsync(UserGroup, Constants.Security.SuperUserKey); + } + + /// + /// Clears the start node for the user group (type-specific implementation). + /// + protected abstract void ClearUserGroupStartNode(IUserGroup userGroup); + + /// + /// Creates a user with the specified start node IDs and returns the calculated start node paths. + /// + protected async Task CreateUserAndGetStartNodePaths(params int[] startNodeIds) + { + var user = await CreateUser(startNodeIds); + var paths = GetStartNodePaths(user); + Assert.IsNotNull(paths); + return paths; + } + + /// + /// Creates a user with the specified start node IDs and returns the calculated start node IDs. + /// + protected async Task CreateUserAndGetStartNodeIds(params int[] startNodeIds) + { + var user = await CreateUser(startNodeIds); + var ids = CalculateStartNodeIds(user); + Assert.IsNotNull(ids); + return ids; + } + + /// + /// Creates a user with the specified start node IDs. + /// + protected async Task CreateUser(int[] startNodeIds) + { + var user = BuildUserWithStartNodes(startNodeIds); + UserService.Save(user); + + var attempt = await UserGroupService.AddUsersToUserGroupAsync( + new UsersToUserGroupManipulationModel(UserGroup.Key, [user.Key]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(attempt.Success); + return user; + } + + /// + /// Builds a user with the specified start node IDs (type-specific implementation). + /// + protected abstract Cms.Core.Models.Membership.User BuildUserWithStartNodes(int[] startNodeIds); + + /// + /// Gets the start node paths for the user (type-specific implementation). + /// + protected abstract string[]? GetStartNodePaths(Cms.Core.Models.Membership.User user); + + /// + /// Calculates the start node IDs for the user (type-specific implementation). + /// + protected abstract int[]? CalculateStartNodeIds(Cms.Core.Models.Membership.User user); +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/User/Current/GetElementPermissionsCurrentUserControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/User/Current/GetElementPermissionsCurrentUserControllerTests.cs new file mode 100644 index 000000000000..b5cd89e040f5 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/User/Current/GetElementPermissionsCurrentUserControllerTests.cs @@ -0,0 +1,30 @@ +using System.Linq.Expressions; +using System.Net; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.User.Current; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.User.Current; + +public class GetElementPermissionsCurrentUserControllerTests : ManagementApiUserGroupTestBase +{ + protected override Expression> MethodSelector + => x => x.GetPermissions(CancellationToken.None, new HashSet()); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.OK }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel + => new() { ExpectedStatusCode = HttpStatusCode.Unauthorized }; +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/DistributedCacheRefresherTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/DistributedCacheRefresherTests.cs index a1e2afbf8286..8d2324531e6e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/DistributedCacheRefresherTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/DistributedCacheRefresherTests.cs @@ -19,6 +19,8 @@ internal sealed class DistributedCacheRefresherTests : UmbracoIntegrationTest private MediaCacheRefresher MediaCacheRefresher => GetRequiredService(); + private ElementCacheRefresher ElementCacheRefresher => GetRequiredService(); + [Test] public void DistributedContentCacheRefresherClearsElementsCache() { @@ -43,6 +45,17 @@ public void DistributedMediaCacheRefresherClearsElementsCache() Assert.IsNull(ElementsCache.Get(cacheKey)); } + [Test] + public void DistributedElementCacheRefresherClearsElementsCache() + { + var cacheKey = "test"; + PopulateCache("test"); + + ElementCacheRefresher.Refresh([new ElementCacheRefresher.JsonPayload(1, Guid.NewGuid(), TreeChangeTypes.RefreshAll)]); + + Assert.IsNull(ElementsCache.Get(cacheKey)); + } + private void PopulateCache(string key) { ElementsCache.Get(key, () => new object()); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Publish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Publish.cs new file mode 100644 index 000000000000..4f81451602ae --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Publish.cs @@ -0,0 +1,163 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ElementPublishingServiceTests +{ + [Test] + public async Task Can_Publish_Invariant() + { + var elementType = await SetupInvariantElementTypeAsync(); + var element = await CreateInvariantContentAsync(elementType); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = null }], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element!.PublishDate); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + Assert.IsTrue(publishedElement.IsPublished()); + } + + [Test] + public async Task Can_Publish_Variant_Single_Culture() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(1, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + Assert.IsTrue(publishedElement.IsPublished(langEn.IsoCode)); + Assert.IsFalse(publishedElement.IsPublished(langDa.IsoCode)); + Assert.IsFalse(publishedElement.IsPublished(langBe.IsoCode)); + } + + [Test] + public async Task Can_Publish_Variant_Some_Cultures() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(2, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + Assert.IsTrue(publishedElement.IsPublished(langEn.IsoCode)); + Assert.IsTrue(publishedElement.IsPublished(langDa.IsoCode)); + Assert.IsFalse(publishedElement.IsPublished(langBe.IsoCode)); + } + + [Test] + public async Task Can_Publish_Variant_All_Cultures() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + new() { Culture = langBe.IsoCode }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(3, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + Assert.IsTrue(publishedElement.IsPublished(langEn.IsoCode)); + Assert.IsTrue(publishedElement.IsPublished(langDa.IsoCode)); + Assert.IsTrue(publishedElement.IsPublished(langBe.IsoCode)); + } + + [Test] + public async Task Cannot_Publish_Trashed() + { + var elementType = await SetupInvariantElementTypeAsync(); + var element = await CreateInvariantContentAsync(elementType); + + await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = null }], + Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InTrash, publishAttempt.Status); + }); + } + + [Test] + public async Task Cannot_Get_Trashed_As_Published() + { + var elementType = await SetupInvariantElementTypeAsync(); + var element = await CreateInvariantContentAsync(elementType); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = null }], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + + await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.IsNull(publishedElement); + } + + [Test] + public async Task Cannot_Get_Published_Again_After_Trashing() + { + var elementType = await SetupInvariantElementTypeAsync(); + var element = await CreateInvariantContentAsync(elementType); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = null }], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + + await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.IsNull(publishedElement); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Unpublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Unpublish.cs new file mode 100644 index 000000000000..060c32e8d0d8 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Unpublish.cs @@ -0,0 +1,114 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ElementPublishingServiceTests +{ + [Test] + public async Task Can_Unpublish_Invariant() + { + var elementType = await SetupInvariantElementTypeAsync(); + var element = await CreateInvariantContentAsync(elementType); + + await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + + var unpublishAttempt = await ElementPublishingService.UnpublishAsync( + element.Key, + null, + Constants.Security.SuperUserKey); + + Assert.IsTrue(unpublishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.IsNull(element!.PublishDate); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.IsNull(publishedElement); + } + + [Test] + public async Task Can_Unpublish_Variant_Single_Culture() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + + var unpublishAttempt = await ElementPublishingService.UnpublishAsync( + element.Key, + new HashSet([langEn.IsoCode]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(unpublishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(0, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.IsNull(publishedElement); + } + + [Test] + public async Task Can_Unpublish_Variant_Some_Cultures() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + await ElementPublishingService.PublishAsync( + element.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + new() { Culture = langBe.IsoCode }, + ], + Constants.Security.SuperUserKey); + + var unpublishAttempt = await ElementPublishingService.UnpublishAsync( + element.Key, + new HashSet([langEn.IsoCode, langDa.IsoCode]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(unpublishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(1, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + Assert.IsFalse(publishedElement.IsPublished(langEn.IsoCode)); + Assert.IsFalse(publishedElement.IsPublished(langDa.IsoCode)); + Assert.IsTrue(publishedElement.IsPublished(langBe.IsoCode)); + } + + [Test] + public async Task Can_Unpublish_Variant_All_Cultures() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + await ElementPublishingService.PublishAsync( + element.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + new() { Culture = langBe.IsoCode }, + ], + Constants.Security.SuperUserKey); + + var unpublishAttempt = await ElementPublishingService.UnpublishAsync( + element.Key, + new HashSet([langEn.IsoCode, langDa.IsoCode, langBe.IsoCode]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(unpublishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(0, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.IsNull(publishedElement); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.cs new file mode 100644 index 000000000000..4aa81786c34c --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.cs @@ -0,0 +1,206 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + PublishedRepositoryEvents = true, + WithApplication = true)] +public partial class ElementPublishingServiceTests : UmbracoIntegrationTest +{ + [SetUp] + public new void Setup() => ContentRepositoryBase.ThrowOnWarning = true; + + [TearDown] + public void Teardown() => ContentRepositoryBase.ThrowOnWarning = false; + + private IElementPublishingService ElementPublishingService => GetRequiredService(); + + private ILanguageService LanguageService => GetRequiredService(); + + private ITemplateService TemplateService => GetRequiredService(); + + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IElementCacheService ElementCacheService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private async Task<(ILanguage LangEn, ILanguage LangDa, ILanguage LangBe, IContentType ContentType)> SetupVariantElementTypeAsync() + { + var langEn = (await LanguageService.GetAsync("en-US"))!; + var langDa = new LanguageBuilder() + .WithCultureInfo("da-DK") + .Build(); + await LanguageService.CreateAsync(langDa, Constants.Security.SuperUserKey); + var langBe = new LanguageBuilder() + .WithCultureInfo("nl-BE") + .Build(); + await LanguageService.CreateAsync(langBe, Constants.Security.SuperUserKey); + + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + var contentType = new ContentTypeBuilder() + .WithAlias("variantContent") + .WithName("Variant Content") + .WithContentVariation(ContentVariation.Culture) + .AddPropertyGroup() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .Done() + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithMandatory(true) + .WithVariations(ContentVariation.Culture) + .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 (langEn, langDa, langBe, contentType); + } + + private async Task CreateVariantElementAsync( + ILanguage langEn, + ILanguage langDa, + ILanguage langBe, + IContentType contentType, + Guid? parentKey = null, + string? englishTitleValue = "Test title") + { + var documentKey = Guid.NewGuid(); + + var createModel = new ElementCreateModel + { + Key = documentKey, + ContentTypeKey = contentType.Key, + ParentKey = parentKey, + Properties = [ + new PropertyValueModel + { + Alias = "title", + Value = englishTitleValue, + Culture = langEn.IsoCode + }, + new PropertyValueModel + { + Alias = "title", + Value = "Test titel", + Culture = langDa.IsoCode + }, + new PropertyValueModel + { + Alias = "title", + Value = "Titel van de test", + Culture = langBe.IsoCode + } + ], + Variants = + [ + new VariantModel { Name = langEn.CultureName, Culture = langEn.IsoCode }, + new VariantModel { Name = langDa.CultureName, Culture = langDa.IsoCode }, + new VariantModel { Name = langBe.CultureName, Culture = langBe.IsoCode } + ], + }; + + var createAttempt = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createAttempt.Success is false) + { + throw new Exception("Something unexpected went wrong setting up the test data"); + } + + return createAttempt.Result.Content!; + } + + private async Task SetupInvariantElementTypeAsync() + { + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + var contentType = new ContentTypeBuilder() + .WithAlias("invariantContent") + .WithName("Invariant Content") + .WithAllowAsRoot(true) + .AddPropertyGroup() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .Done() + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithMandatory(true) + .Done() + .Build(); + + 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, Guid? parentKey = null, string? titleValue = "Test title") + { + var documentKey = Guid.NewGuid(); + + var createModel = new ElementCreateModel + { + Key = documentKey, + ContentTypeKey = contentType.Key, + Variants = [new () { Name = "Test" }], + ParentKey = parentKey, + Properties = + [ + new PropertyValueModel + { + Alias = "title", + Value = titleValue, + } + ], + }; + + var createAttempt = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createAttempt.Success is false) + { + throw new Exception($"Something unexpected went wrong setting up the test data. Status: {createAttempt.Status}"); + } + + return createAttempt.Result.Content!; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementServiceTests.cs new file mode 100644 index 000000000000..4fb9d6112b13 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementServiceTests.cs @@ -0,0 +1,46 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + PublishedRepositoryEvents = true, + WithApplication = true)] +public class ElementServiceTests : UmbracoIntegrationTest +{ + private IElementService ElementService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + [Test] + public async Task Can_Save_Element() + { + var elementType = ContentTypeBuilder.CreateSimpleElementType(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Arrange + // TODO ELEMENTS: This seems to be a leftover from early POC implementation; IElementService.Create() is not + // used anywhere else than this. Should probably be removed. + var element = ElementService.Create("My Element", elementType.Alias); + element.SetValue("title", "The Element Title"); + + // Act + var result = ElementService.Save(element); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Result, Is.EqualTo(OperationResultType.Success)); + + element = ElementService.GetById(element.Key); + Assert.That(element, Is.Not.Null); + Assert.That(element.HasIdentity, Is.True); + Assert.That(element.Name, Is.EqualTo("My Element")); + Assert.That(element.GetValue("title"), Is.EqualTo("The Element Title")); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs index a7afcb6c9d2a..eef13b463a5b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs @@ -46,7 +46,7 @@ public void GetDocumentVersionsEligibleForCleanup_Always_ExcludesActiveVersions( using (ScopeProvider.CreateScope()) { var sut = new DocumentVersionRepository(ScopeAccessor); - var results = sut.GetDocumentVersionsEligibleForCleanup(); + var results = sut.GetContentVersionsEligibleForCleanup(); Assert.Multiple(() => { @@ -85,7 +85,7 @@ public void GetDocumentVersionsEligibleForCleanup_Always_ExcludesPinnedVersions( ScopeAccessor.AmbientScope.Database.Update("set preventCleanup = 1 where id in (1,3)"); var sut = new DocumentVersionRepository(ScopeAccessor); - var results = sut.GetDocumentVersionsEligibleForCleanup(); + var results = sut.GetContentVersionsEligibleForCleanup(); Assert.Multiple(() => { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementRepositoryTest.cs new file mode 100644 index 000000000000..f62b83faf9f7 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementRepositoryTest.cs @@ -0,0 +1,362 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Persistence.Repositories; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class ElementRepositoryTest : UmbracoIntegrationTest +{ + [SetUp] + public void SetUpData() + { + ContentRepositoryBase.ThrowOnWarning = true; + } + + [TearDown] + public void Teardown() => ContentRepositoryBase.ThrowOnWarning = false; + + private ContentType _contentType; + + private IDataTypeService DataTypeService => GetRequiredService(); + + private PropertyEditorCollection PropertyEditorCollection => GetRequiredService(); + + private IDataValueEditorFactory DataValueEditorFactory => GetRequiredService(); + + private IConfigurationEditorJsonSerializer ConfigurationEditorJsonSerializer => + GetRequiredService(); + + private ElementRepository CreateRepository(IScopeAccessor scopeAccessor, out ContentTypeRepository contentTypeRepository, out DataTypeRepository dtdRepository, AppCaches appCaches = null) + { + appCaches ??= AppCaches; + + var ctRepository = CreateRepository(scopeAccessor, out contentTypeRepository); + var editors = new PropertyEditorCollection(new DataEditorCollection(() => Enumerable.Empty())); + dtdRepository = new DataTypeRepository( + scopeAccessor, + appCaches, + editors, + LoggerFactory.CreateLogger(), + LoggerFactory, + ConfigurationEditorJsonSerializer, + Mock.Of(), + Mock.Of(), + DataValueEditorFactory); + return ctRepository; + } + + private ElementRepository CreateRepository(IScopeAccessor scopeAccessor, out ContentTypeRepository contentTypeRepository, AppCaches appCaches = null) + { + appCaches ??= AppCaches; + + var runtimeSettingsMock = new Mock>(); + runtimeSettingsMock.Setup(x => x.CurrentValue).Returns(new RuntimeSettings()); + + var templateRepository = new TemplateRepository(scopeAccessor, appCaches, LoggerFactory.CreateLogger(), GetRequiredService(), ShortStringHelper, Mock.Of(), runtimeSettingsMock.Object, Mock.Of(), Mock.Of()); + var tagRepository = new TagRepository(scopeAccessor, appCaches, LoggerFactory.CreateLogger(), Mock.Of(), Mock.Of()); + var commonRepository = + new ContentTypeCommonRepository(scopeAccessor, templateRepository, appCaches, ShortStringHelper); + var languageRepository = + new LanguageRepository(scopeAccessor, appCaches, LoggerFactory.CreateLogger(), Mock.Of(), Mock.Of()); + contentTypeRepository = new ContentTypeRepository(scopeAccessor, appCaches, LoggerFactory.CreateLogger(), commonRepository, languageRepository, ShortStringHelper, Mock.Of(), IdKeyMap, Mock.Of()); + var relationTypeRepository = new RelationTypeRepository(scopeAccessor, AppCaches.Disabled, LoggerFactory.CreateLogger(), Mock.Of(), Mock.Of()); + var entityRepository = new EntityRepository(scopeAccessor, AppCaches.Disabled); + var relationRepository = new RelationRepository(scopeAccessor, LoggerFactory.CreateLogger(), relationTypeRepository, entityRepository, Mock.Of(), Mock.Of()); + var propertyEditors = + new PropertyEditorCollection(new DataEditorCollection(() => Enumerable.Empty())); + var dataValueReferences = + new DataValueReferenceFactoryCollection(() => Enumerable.Empty(), new NullLogger()); + var repository = new ElementRepository( + scopeAccessor, + appCaches, + LoggerFactory.CreateLogger(), + LoggerFactory, + contentTypeRepository, + tagRepository, + languageRepository, + relationRepository, + relationTypeRepository, + propertyEditors, + dataValueReferences, + DataTypeService, + ConfigurationEditorJsonSerializer, + Mock.Of()); + return repository; + } + + [Test] + public void CacheActiveForIntsAndGuids() + { + var realCache = new AppCaches( + new ObjectCacheAppCache(), + new DictionaryAppCache(), + new IsolatedCaches(t => new ObjectCacheAppCache())); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using (var scope = provider.CreateScope()) + { + var repository = CreateRepository((IScopeAccessor)provider, out var contentTypeRepository, realCache); + + var udb = scopeAccessor.AmbientScope.Database; + + udb.EnableSqlCount = false; + + var contentType = ContentTypeBuilder.CreateBasicElementType(); + contentTypeRepository.Save(contentType); + var content = ElementBuilder.CreateBasicElement(contentType); + repository.Save(content); + + udb.EnableSqlCount = true; + + // go get it, this should already be cached since the default repository key is the INT + repository.Get(content.Id); + Assert.AreEqual(0, udb.SqlCount); + + // retrieve again, this should use cache + repository.Get(content.Id); + Assert.AreEqual(0, udb.SqlCount); + + // reset counter + udb.EnableSqlCount = false; + udb.EnableSqlCount = true; + + // now get by GUID, this won't be cached yet because the default repo key is not a GUID + repository.Get(content.Key); + var sqlCount = udb.SqlCount; + Assert.Greater(sqlCount, 0); + + // retrieve again, this should use cache now + repository.Get(content.Key); + Assert.AreEqual(sqlCount, udb.SqlCount); + } + } + + [Test] + public void CreateVersions() + { + var provider = ScopeProvider; + using (var scope = provider.CreateScope()) + { + var repository = CreateRepository((IScopeAccessor)provider, out var contentTypeRepository, out DataTypeRepository _); + var versions = new List(); + var hasPropertiesContentType = ContentTypeBuilder.CreateSimpleElementType(); + contentTypeRepository.Save(hasPropertiesContentType); + + IElement element1 = ElementBuilder.CreateSimpleElement(hasPropertiesContentType); + + // save = create the initial version + repository.Save(element1); + + versions.Add(element1.VersionId); // the first version + + // publish = new edit version + element1.SetValue("title", "title"); + element1.PublishCulture(CultureImpact.Invariant, DateTime.Now, PropertyEditorCollection); + element1.PublishedState = PublishedState.Publishing; + repository.Save(element1); + + versions.Add(element1.VersionId); // NEW VERSION + + // new edit version has been created + Assert.AreNotEqual(versions[^2], versions[^1]); + Assert.IsTrue(element1.Published); + Assert.AreEqual(PublishedState.Published, element1.PublishedState); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual(true, ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // change something + // save = update the current (draft) version + element1.Name = "name-1"; + element1.SetValue("title", "title-1"); + repository.Save(element1); + + versions.Add(element1.VersionId); // the same version + + // no new version has been created + Assert.AreEqual(versions[^2], versions[^1]); + Assert.IsTrue(element1.Published); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + true, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // unpublish = no impact on versions + element1.PublishedState = PublishedState.Unpublishing; + repository.Save(element1); + + versions.Add(element1.VersionId); // the same version + + // no new version has been created + Assert.AreEqual(versions[^2], versions[^1]); + Assert.IsFalse(element1.Published); + Assert.AreEqual(PublishedState.Unpublished, element1.PublishedState); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + false, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // change something + // save = update the current (draft) version + element1.Name = "name-2"; + element1.SetValue("title", "title-2"); + repository.Save(element1); + + versions.Add(element1.VersionId); // the same version + + // no new version has been created + Assert.AreEqual(versions[^2], versions[^1]); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + false, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // publish = version + element1.PublishCulture(CultureImpact.Invariant, DateTime.Now, PropertyEditorCollection); + element1.PublishedState = PublishedState.Publishing; + repository.Save(element1); + + versions.Add(element1.VersionId); // NEW VERSION + + // new version has been created + Assert.AreNotEqual(versions[^2], versions[^1]); + Assert.IsTrue(element1.Published); + Assert.AreEqual(PublishedState.Published, element1.PublishedState); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + true, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // change something + // save = update the current (draft) version + element1.Name = "name-3"; + element1.SetValue("title", "title-3"); + + //// Thread.Sleep(2000); // force date change + + repository.Save(element1); + + versions.Add(element1.VersionId); // the same version + + // no new version has been created + Assert.AreEqual(versions[^2], versions[^1]); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + true, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // publish = new version + element1.Name = "name-4"; + element1.SetValue("title", "title-4"); + element1.PublishCulture(CultureImpact.Invariant, DateTime.Now, PropertyEditorCollection); + element1.PublishedState = PublishedState.Publishing; + repository.Save(element1); + + versions.Add(element1.VersionId); // NEW VERSION + + // a new version has been created + Assert.AreNotEqual(versions[^2], versions[^1]); + Assert.IsTrue(element1.Published); + Assert.AreEqual(PublishedState.Published, element1.PublishedState); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + true, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // all versions + var allVersions = repository.GetAllVersions(element1.Id).ToArray(); + Assert.Multiple(() => + { + Assert.AreEqual(4, allVersions.Length); + Assert.IsTrue(allVersions.All(v => v.PublishedVersionId == 3)); + Assert.AreEqual(4, allVersions.DistinctBy(v => v.VersionId).Count()); + for (var versionId = 1; versionId <= 4; versionId++) + { + Assert.IsNotNull(allVersions.FirstOrDefault(v => v.VersionId == versionId)); + } + }); + + // Console.WriteLine(); + // foreach (var v in versions) + // { + // Console.WriteLine(v); + // } + // + // Console.WriteLine(); + // foreach (var v in allVersions) + // { + // Console.WriteLine($"{v.Id} {v.VersionId} {(v.Published ? "+" : "-")}pub pk={v.VersionId} ppk={v.PublishedVersionId} name=\"{v.Name}\" pname=\"{v.PublishName}\""); + // } + + // get older version + var element = repository.GetVersion(versions[^4]); + Assert.AreNotEqual(0, element.VersionId); + Assert.AreEqual(versions[^4], element.VersionId); + Assert.AreEqual("name-4", element1.Name); + Assert.AreEqual("title-4", element1.GetValue("title")); + Assert.AreEqual("name-2", element.Name); + Assert.AreEqual("title-2", element.GetValue("title")); + + // get all versions - most recent first + allVersions = repository.GetAllVersions(element1.Id).ToArray(); + var expVersions = versions.Distinct().Reverse().ToArray(); + Assert.AreEqual(expVersions.Length, allVersions.Length); + for (var i = 0; i < expVersions.Length; i++) + { + Assert.AreEqual(expVersions[i], allVersions[i].VersionId); + } + } + } + + // TODO ELEMENTS: port over all relevant tests from DocumentRepositoryTest +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementVersionRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementVersionRepositoryTest.cs new file mode 100644 index 000000000000..3dae61f662b3 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementVersionRepositoryTest.cs @@ -0,0 +1,186 @@ +using System.Diagnostics; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Persistence.Repositories; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class ElementVersionRepositoryTest : UmbracoIntegrationTest +{ + public IContentTypeService ContentTypeService => GetRequiredService(); + + public IElementService ElementService => GetRequiredService(); + + [Test] + public void GetElementVersionsEligibleForCleanup_Always_ExcludesActiveVersions() + { + var contentType = ContentTypeBuilder.CreateSimpleElementType(); + ContentTypeService.Save(contentType); + + var content = ElementBuilder.CreateSimpleElement(contentType); + ElementService.Save(content); + + ElementService.Publish(content, Array.Empty()); + // At this point content has 2 versions, a draft version and a published version. + + ElementService.Publish(content, Array.Empty()); + // At this point content has 3 versions, a historic version, a draft version and a published version. + + using (ScopeProvider.CreateScope()) + { + var sut = new ElementVersionRepository(ScopeAccessor); + var results = sut.GetContentVersionsEligibleForCleanup(); + + Assert.Multiple(() => + { + Assert.AreEqual(1, results.Count); + Assert.AreEqual(1, results.First().VersionId); + }); + } + } + + [Test] + public void GetElementVersionsEligibleForCleanup_Always_ExcludesPinnedVersions() + { + var contentType = ContentTypeBuilder.CreateSimpleElementType(); + ContentTypeService.Save(contentType); + + var content = ElementBuilder.CreateSimpleElement(contentType); + ElementService.Save(content); + + ElementService.Publish(content, Array.Empty()); + // At this point content has 2 versions, a draft version and a published version. + ElementService.Publish(content, Array.Empty()); + ElementService.Publish(content, Array.Empty()); + ElementService.Publish(content, Array.Empty()); + // At this point content has 5 versions, 3 historic versions, a draft version and a published version. + + var allVersions = ElementService.GetVersions(content.Id); + Debug.Assert(allVersions.Count() == 5); // Sanity check + + using (var scope = ScopeProvider.CreateScope()) + { + ScopeAccessor.AmbientScope.Database.Update("set preventCleanup = 1 where id in (1,3)"); + + var sut = new ElementVersionRepository(ScopeAccessor); + var results = sut.GetContentVersionsEligibleForCleanup(); + + Assert.Multiple(() => + { + Assert.AreEqual(1, results.Count); + + // We pinned 1 & 3 + // 4 is current + // 5 is published + // So all that is left is 2 + Assert.AreEqual(2, results.First().VersionId); + }); + } + } + + [Test] + public void DeleteVersions_Always_DeletesSpecifiedVersions() + { + var contentType = ContentTypeBuilder.CreateSimpleElementType(); + ContentTypeService.Save(contentType); + + var content = ElementBuilder.CreateSimpleElement(contentType); + ElementService.Save(content); + + ElementService.Publish(content, Array.Empty()); + ElementService.Publish(content, Array.Empty()); + ElementService.Publish(content, Array.Empty()); + ElementService.Publish(content, Array.Empty()); + using (var scope = ScopeProvider.CreateScope()) + { + var query = ScopeAccessor.AmbientScope.SqlContext.Sql(); + + query.Select() + .From(); + + var sut = new ElementVersionRepository(ScopeAccessor); + sut.DeleteVersions(new[] { 1, 2, 3 }); + + var after = ScopeAccessor.AmbientScope.Database.Fetch(query); + + Assert.Multiple(() => + { + Assert.AreEqual(2, after.Count); + Assert.True(after.All(x => x.Id > 3)); + }); + } + } + + + [Test] + public void GetPagedItemsByContentId_WithInvariantCultureContent_ReturnsPaginatedResults() + { + var contentType = ContentTypeBuilder.CreateSimpleElementType(); + ContentTypeService.Save(contentType); + + var content = ElementBuilder.CreateSimpleElement(contentType); + ElementService.Save(content); + + ElementService.Publish(content, Array.Empty()); // Draft + Published + ElementService.Publish(content, Array.Empty()); // New Draft + + using (ScopeProvider.CreateScope()) + { + var sut = new ElementVersionRepository((IScopeAccessor)ScopeProvider); + var page1 = sut.GetPagedItemsByContentId(content.Id, 0, 2, out var page1Total); + var page2 = sut.GetPagedItemsByContentId(content.Id, 1, 2, out var page2Total); + + Assert.Multiple(() => + { + Assert.AreEqual(2, page1.Count()); + Assert.AreEqual(3, page1Total); + + Assert.AreEqual(1, page2.Count()); + Assert.AreEqual(3, page2Total); + }); + } + } + + [Test] + public void GetPagedItemsByContentId_WithVariantCultureContent_ReturnsPaginatedResults() + { + var contentType = ContentTypeBuilder.CreateSimpleElementType(); + contentType.Variations = ContentVariation.Culture; + foreach (var propertyType in contentType.PropertyTypes) + { + propertyType.Variations = ContentVariation.Culture; + } + ContentTypeService.Save(contentType); + + var content = ElementBuilder.CreateSimpleElement(contentType, "foo", culture: "en-US"); + content.SetCultureName("foo", "en-US"); + + ElementService.Save(content); + ElementService.Publish(content, new[] { "en-US" }); // Draft + Published + ElementService.Publish(content, new[] { "en-US" }); // New Draft + + using (ScopeProvider.CreateScope()) + { + var sut = new ElementVersionRepository((IScopeAccessor)ScopeProvider); + var page1 = sut.GetPagedItemsByContentId(content.Id, 0, 2, out var page1Total, 1); + var page2 = sut.GetPagedItemsByContentId(content.Id, 1, 2, out var page2Total, 1); + + Assert.Multiple(() => + { + Assert.AreEqual(2, page1.Count()); + Assert.AreEqual(3, page1Total); + + Assert.AreEqual(1, page2.Count()); + Assert.AreEqual(3, page2Total); + }); + } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Create.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Create.cs new file mode 100644 index 000000000000..a1f418f97aca --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Create.cs @@ -0,0 +1,85 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementContainerServiceTests +{ + [Test] + public async Task Can_Create_Container_At_Root() + { + var result = await ElementContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + var created = await ElementContainerService.GetAsync(result.Result.Key); + Assert.NotNull(created); + Assert.Multiple(() => + { + Assert.AreEqual("Root Container", created.Name); + Assert.AreEqual(Constants.System.Root, created.ParentId); + }); + } + + [Test] + public async Task Can_Create_Child_Container() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey)).Result; + + var result = await ElementContainerService.CreateAsync(null, "Child Container", root.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + var created = await ElementContainerService.GetAsync(result.Result.Key); + Assert.NotNull(created); + Assert.Multiple(() => + { + Assert.AreEqual("Child Container", created.Name); + Assert.AreEqual(root.Id, created.ParentId); + }); + } + + [Test] + public async Task Can_Create_Container_With_Explicit_Key() + { + var key = Guid.NewGuid(); + var result = await ElementContainerService.CreateAsync(key, "Root Container", null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + Assert.AreEqual(key, result.Result.Key); + }); + + var created = await ElementContainerService.GetAsync(key); + Assert.NotNull(created); + Assert.Multiple(() => + { + Assert.AreEqual("Root Container", created.Name); + Assert.AreEqual(Constants.System.Root, created.ParentId); + }); + } + + [Test] + public async Task Cannot_Create_Child_Container_Below_Invalid_Parent() + { + var key = Guid.NewGuid(); + var result = await ElementContainerService.CreateAsync(key, "Child Container", Guid.NewGuid(), Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.ParentNotFound, result.Status); + }); + + var created = await ElementContainerService.GetAsync(key); + Assert.IsNull(created); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Delete.cs new file mode 100644 index 000000000000..c3a2b9b0149a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Delete.cs @@ -0,0 +1,154 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementContainerServiceTests +{ + [Test] + public async Task Can_Delete_Container_At_Root() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null,"Root Container", null, Constants.Security.SuperUserKey)).Result; + + var result = await ElementContainerService.DeleteAsync(root.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + var current = await ElementContainerService.GetAsync(root.Key); + Assert.IsNull(current); + } + + [Test] + public async Task Can_Delete_Child_Container() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null,"Root Container", null, Constants.Security.SuperUserKey)).Result; + EntityContainer child = (await ElementContainerService.CreateAsync(null, "Child Container", root.Key, Constants.Security.SuperUserKey)).Result; + + var result = await ElementContainerService.DeleteAsync(child.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + child = await ElementContainerService.GetAsync(child.Key); + Assert.IsNull(child); + + root = await ElementContainerService.GetAsync(root.Key); + Assert.IsNotNull(root); + } + + [Test] + public async Task Cannot_Delete_Container_With_Child_Container() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null,"Root Container", null, Constants.Security.SuperUserKey)).Result; + EntityContainer child = (await ElementContainerService.CreateAsync(null, "Child Container", root.Key, Constants.Security.SuperUserKey)).Result; + + var result = await ElementContainerService.DeleteAsync(root.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.NotEmpty, result.Status); + }); + + var current = await ElementContainerService.GetAsync(root.Key); + Assert.IsNotNull(current); + } + + [Test] + public async Task Cannot_Delete_Non_Existing_Container() + { + var result = await ElementContainerService.DeleteAsync(Guid.NewGuid(), Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.NotFound, result.Status); + }); + } + + [Test] + public async Task Container_Delete_Events_Are_Fired() + { + var deletingWasCalled = false; + var deletedWasCalled = false; + + var containerKey = Guid.NewGuid(); + var container = (await ElementContainerService.CreateAsync(containerKey, "The Container", null, Constants.Security.SuperUserKey)).Result; + Assert.IsNotNull(container); + + try + { + EntityContainerNotificationHandler.DeletingContainer = notification => + { + deletingWasCalled = true; + Assert.AreEqual(containerKey, notification.DeletedEntities.Single().Key); + }; + + EntityContainerNotificationHandler.DeletedContainer = notification => + { + deletedWasCalled = true; + Assert.AreEqual(containerKey, notification.DeletedEntities.Single().Key); + }; + + var result = await ElementContainerService.DeleteAsync(containerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + Assert.IsTrue(result.Success); + Assert.IsTrue(deletingWasCalled); + Assert.IsTrue(deletedWasCalled); + } + finally + { + EntityContainerNotificationHandler.DeletingContainer = null; + EntityContainerNotificationHandler.DeletedContainer = null; + } + + Assert.AreEqual(0, GetAtRoot().Length); + Assert.IsNull(await ElementContainerService.GetAsync(containerKey)); + } + + [Test] + public async Task Container_Delete_Event_Can_Be_Cancelled() + { + var deletingWasCalled = false; + var deletedWasCalled = false; + + var containerKey = Guid.NewGuid(); + var container = (await ElementContainerService.CreateAsync(containerKey, "The Container", null, Constants.Security.SuperUserKey)).Result; + Assert.IsNotNull(container); + + try + { + EntityContainerNotificationHandler.DeletingContainer = notification => + { + deletingWasCalled = true; + notification.Cancel = true; + }; + + EntityContainerNotificationHandler.DeletedContainer = _ => + { + deletedWasCalled = true; + }; + + var result = await ElementContainerService.DeleteAsync(containerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(EntityContainerOperationStatus.CancelledByNotification, result.Status); + Assert.IsFalse(result.Success); + Assert.IsTrue(deletingWasCalled); + Assert.IsFalse(deletedWasCalled); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.IsNotNull(await ElementContainerService.GetAsync(containerKey)); + } + finally + { + EntityContainerNotificationHandler.DeletingContainer = null; + EntityContainerNotificationHandler.DeletedContainer = null; + } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.DeleteFromRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.DeleteFromRecycleBin.cs new file mode 100644 index 000000000000..77af33ad6e7d --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.DeleteFromRecycleBin.cs @@ -0,0 +1,163 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Attributes; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementContainerServiceTests +{ + [Test] + public async Task Can_Delete_Empty_Container_From_Recycle_Bin() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + await ElementContainerService.MoveToRecycleBinAsync(rootContainerKey, Constants.Security.SuperUserKey); + + await AssertContainerIsInRecycleBin(rootContainerKey); + + var deleteResult = await ElementContainerService.DeleteFromRecycleBinAsync(rootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(deleteResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, deleteResult.Status); + }); + + // verify that the deletion happened + var rootContainer = await ElementContainerService.GetAsync(rootContainerKey); + Assert.IsNull(rootContainer); + } + + [Test] + [LongRunning] + public async Task Can_Delete_Container_With_Descendant_Containers_And_Lots_Of_Elements_From_Recycle_Bin() + { + var setup = await CreateContainerWithDescendantContainersAndLotsOfElements(false); + + var moveResult = await ElementContainerService.MoveToRecycleBinAsync(setup.RootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + Assert.AreNotEqual(0, EntityService.GetDescendants(Constants.System.RecycleBinElement).Count()); + + var deleteResult = await ElementContainerService.DeleteFromRecycleBinAsync(setup.RootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(deleteResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, deleteResult.Status); + }); + + Assert.AreEqual(0, EntityService.GetDescendants(Constants.System.RecycleBinElement).Count()); + } + + [Test] + public async Task Cannot_Delete_Untrashed_Container_From_Recycle_Bin() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + + Assert.AreEqual(1, GetAtRoot().Length); + + var deleteResult = await ElementContainerService.DeleteFromRecycleBinAsync(rootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(deleteResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.NotInTrash, deleteResult.Status); + }); + + // verify that the deletion did not happen + var rootContainer = await ElementContainerService.GetAsync(rootContainerKey); + Assert.IsNotNull(rootContainer); + Assert.AreEqual(Constants.System.Root, rootContainer.ParentId); + } + + [Test] + public async Task Container_Delete_From_Recycle_Bin_Events_Are_Fired() + { + var deletingWasCalled = false; + var deletedWasCalled = false; + + var containerKey = Guid.NewGuid(); + var container = (await ElementContainerService.CreateAsync(containerKey, "The Container", null, Constants.Security.SuperUserKey)).Result; + Assert.IsNotNull(container); + + await ElementContainerService.MoveToRecycleBinAsync(containerKey, Constants.Security.SuperUserKey); + + try + { + EntityContainerNotificationHandler.DeletingContainer = notification => + { + deletingWasCalled = true; + Assert.AreEqual(containerKey, notification.DeletedEntities.Single().Key); + }; + + EntityContainerNotificationHandler.DeletedContainer = notification => + { + deletedWasCalled = true; + Assert.AreEqual(containerKey, notification.DeletedEntities.Single().Key); + }; + + var result = await ElementContainerService.DeleteFromRecycleBinAsync(containerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + Assert.IsTrue(result.Success); + Assert.IsTrue(deletingWasCalled); + Assert.IsTrue(deletedWasCalled); + } + finally + { + EntityContainerNotificationHandler.DeletingContainer = null; + EntityContainerNotificationHandler.DeletedContainer = null; + } + + Assert.AreEqual(0, GetAtRoot().Length); + Assert.IsNull(await ElementContainerService.GetAsync(containerKey)); + } + + [Test] + public async Task Container_Delete_From_Recycle_Bin_Event_Can_Be_Cancelled() + { + var deletingWasCalled = false; + var deletedWasCalled = false; + + var containerKey = Guid.NewGuid(); + var container = (await ElementContainerService.CreateAsync(containerKey, "The Container", null, Constants.Security.SuperUserKey)).Result; + Assert.IsNotNull(container); + + await ElementContainerService.MoveToRecycleBinAsync(containerKey, Constants.Security.SuperUserKey); + + try + { + EntityContainerNotificationHandler.DeletingContainer = notification => + { + deletingWasCalled = true; + notification.Cancel = true; + }; + + EntityContainerNotificationHandler.DeletedContainer = notification => + { + deletedWasCalled = true; + }; + + var result = await ElementContainerService.DeleteFromRecycleBinAsync(containerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(EntityContainerOperationStatus.CancelledByNotification, result.Status); + Assert.IsFalse(result.Success); + Assert.IsTrue(deletingWasCalled); + Assert.IsFalse(deletedWasCalled); + } + finally + { + EntityContainerNotificationHandler.DeletingContainer = null; + EntityContainerNotificationHandler.DeletedContainer = null; + } + + var entityContainer = await ElementContainerService.GetAsync(containerKey); + Assert.IsNotNull(entityContainer); + Assert.IsTrue(entityContainer.Trashed); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.EmptyRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.EmptyRecycleBin.cs new file mode 100644 index 000000000000..e2e6b22af7d3 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.EmptyRecycleBin.cs @@ -0,0 +1,85 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Tests.Common.Attributes; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementContainerServiceTests +{ + [Test] + public async Task Can_Purge_Empty_Containers_From_Recycle_Bin() + { + for (var i = 0; i < 5; i++) + { + var key = Guid.NewGuid(); + await ElementContainerService.CreateAsync(key, $"Root Container {i}", null, Constants.Security.SuperUserKey); + await ElementContainerService.MoveToRecycleBinAsync(key, Constants.Security.SuperUserKey); + } + + Assert.AreEqual(5, EntityService.GetDescendants(Constants.System.RecycleBinElement).Count()); + + var emptyResult = await ElementContainerService.EmptyRecycleBinAsync(Constants.Security.SuperUserKey); + + Assert.IsTrue(emptyResult); + Assert.AreEqual(0, EntityService.GetDescendants(Constants.System.RecycleBinElement).Count()); + } + + [Test] + [LongRunning] + public async Task Can_Purge_Container_With_Descendant_Containers_And_Lots_Of_Elements_From_Recycle_Bin() + { + var setup = await CreateContainerWithDescendantContainersAndLotsOfElements(false); + await ElementContainerService.MoveToRecycleBinAsync(setup.RootContainerKey, Constants.Security.SuperUserKey); + Assert.AreNotEqual(0, EntityService.GetDescendants(Constants.System.RecycleBinElement).Count()); + + var emptyResult = await ElementContainerService.EmptyRecycleBinAsync(Constants.Security.SuperUserKey); + + Assert.IsTrue(emptyResult); + Assert.AreEqual(0, EntityService.GetDescendants(Constants.System.RecycleBinElement).Count()); + } + + [Test] + public async Task Emptying_The_Recycle_Bin_Does_Not_Affect_Items_Outside_The_Recycle_Bin() + { + var elementType = await CreateElementType(); + + await CreateElement(elementType.Key, null); + var elementToBin = await CreateElement(elementType.Key, null); + await ElementEditingService.MoveToRecycleBinAsync(elementToBin.Key, Constants.Security.SuperUserKey); + for (var i = 0; i < 5; i++) + { + var key = Guid.NewGuid(); + await ElementContainerService.CreateAsync(key, $"Root Container {i}", null, Constants.Security.SuperUserKey); + await CreateElement(elementType.Key, key); + if (i % 2 == 0) + { + await ElementContainerService.MoveToRecycleBinAsync(key, Constants.Security.SuperUserKey); + } + } + + Assert.AreEqual(1, EntityService.GetRootEntities(UmbracoObjectTypes.Element).Count()); + var rootEntities = EntityService.GetRootEntities(UmbracoObjectTypes.ElementContainer).ToArray(); + Assert.AreEqual(2, rootEntities.Length); + foreach (var rootEntity in rootEntities) + { + Assert.AreEqual(1, GetFolderChildren(rootEntity.Key).Length); + } + + // trashed root element + three trashed folders, each containing a single element + Assert.AreEqual(7, EntityService.GetDescendants(Constants.System.RecycleBinElement).Count()); + + var emptyResult = await ElementContainerService.EmptyRecycleBinAsync(Constants.Security.SuperUserKey); + Assert.IsTrue(emptyResult); + + Assert.AreEqual(1, EntityService.GetRootEntities(UmbracoObjectTypes.Element).Count()); + rootEntities = EntityService.GetRootEntities(UmbracoObjectTypes.ElementContainer).ToArray(); + Assert.AreEqual(2, rootEntities.Length); + foreach (var rootEntity in rootEntities) + { + Assert.AreEqual(1, GetFolderChildren(rootEntity.Key).Length); + } + + Assert.AreEqual(0, EntityService.GetDescendants(Constants.System.RecycleBinElement).Count()); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Get.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Get.cs new file mode 100644 index 000000000000..9ec654001de6 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Get.cs @@ -0,0 +1,37 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementContainerServiceTests +{ + [Test] + public async Task Can_Get_Container_At_Root() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null,"Root Container", null, Constants.Security.SuperUserKey)).Result; + + EntityContainer created = await ElementContainerService.GetAsync(root.Key); + Assert.NotNull(created); + Assert.Multiple(() => + { + Assert.AreEqual("Root Container", created.Name); + Assert.AreEqual(Constants.System.Root, created.ParentId); + }); + } + + [Test] + public async Task Can_Get_Child_Container() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null,"Root Container", null, Constants.Security.SuperUserKey)).Result; + EntityContainer child = (await ElementContainerService.CreateAsync(null, "Child Container", root.Key, Constants.Security.SuperUserKey)).Result; + + EntityContainer created = await ElementContainerService.GetAsync(child.Key); + Assert.IsNotNull(created); + Assert.Multiple(() => + { + Assert.AreEqual("Child Container", created.Name); + Assert.AreEqual(root.Id, child.ParentId); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Move.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Move.cs new file mode 100644 index 000000000000..6ffa90fd0f43 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Move.cs @@ -0,0 +1,657 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Attributes; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementContainerServiceTests +{ + [Test] + public async Task Can_Move_Empty_Container_From_Root_To_Another_Container() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + + var otherRootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(otherRootContainerKey, "Other Root Container", null, Constants.Security.SuperUserKey); + + var childContainerKey = Guid.NewGuid(); + var childContainer = (await ElementContainerService.CreateAsync(childContainerKey, "Child Container", otherRootContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(childContainer); + + Assert.AreEqual(0, GetFolderChildren(childContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(otherRootContainerKey).Length); + + var moveResult = await ElementContainerService.MoveAsync(rootContainerKey, childContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + + var rootContainer = await ElementContainerService.GetAsync(rootContainerKey); + Assert.NotNull(rootContainer); + var otherRootContainer = await ElementContainerService.GetAsync(otherRootContainerKey); + Assert.NotNull(otherRootContainer); + + Assert.AreEqual(childContainer.Id, rootContainer.ParentId); + Assert.AreEqual($"{Constants.System.Root},{otherRootContainer.Id},{childContainer.Id},{rootContainer.Id}", rootContainer.Path); + Assert.AreEqual(childContainer.Level + 1, rootContainer.Level); + } + + [Test] + public async Task Can_Move_Empty_Container_From_Another_Container_To_Root() + { + var rootContainerKey = Guid.NewGuid(); + var rootContainer = (await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey)).Result; + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + + var childContainerKey = Guid.NewGuid(); + var childContainer = (await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(childContainer); + Assert.AreEqual(rootContainer.Id, childContainer.ParentId); + Assert.AreEqual($"{rootContainer.Path},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(2, childContainer.Level); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.AreEqual(1, GetFolderChildren(rootContainerKey).Length); + + var moveResult = await ElementContainerService.MoveAsync(childContainerKey, null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + Assert.AreEqual(2, GetAtRoot().Length); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + + childContainer = await ElementContainerService.GetAsync(childContainerKey); + Assert.NotNull(childContainer); + Assert.AreEqual(Constants.System.Root, childContainer.ParentId); + Assert.AreEqual($"{Constants.System.Root},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(1, childContainer.Level); + } + + [Test] + public async Task Can_Move_Container_With_Descendant_Containers_From_Another_Container_To_Root() + { + var rootContainerKey = Guid.NewGuid(); + var rootContainer = (await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(rootContainer); + + var childContainerKey = Guid.NewGuid(); + var childContainer = (await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(childContainer); + Assert.AreEqual(rootContainer.Id, childContainer.ParentId); + Assert.AreEqual($"{rootContainer.Path},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(2, childContainer.Level); + + var grandchildContainerKey = Guid.NewGuid(); + var grandchildContainer = (await ElementContainerService.CreateAsync(grandchildContainerKey, "Grandchild Container", childContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(grandchildContainer); + Assert.AreEqual(childContainer.Id, grandchildContainer.ParentId); + Assert.AreEqual($"{childContainer.Path},{grandchildContainer.Id}", grandchildContainer.Path); + Assert.AreEqual(3, grandchildContainer.Level); + + var greatGrandchildContainerKey = Guid.NewGuid(); + var greatGrandchildContainer = (await ElementContainerService.CreateAsync(greatGrandchildContainerKey, "Great Grandchild Container", grandchildContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(greatGrandchildContainer); + Assert.AreEqual(grandchildContainer.Id, greatGrandchildContainer.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{greatGrandchildContainer.Id}", greatGrandchildContainer.Path); + Assert.AreEqual(4, greatGrandchildContainer.Level); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.AreEqual(1, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(grandchildContainerKey).Length); + Assert.AreEqual(0, GetFolderChildren(greatGrandchildContainerKey).Length); + + var moveResult = await ElementContainerService.MoveAsync(childContainerKey, null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + Assert.AreEqual(2, GetAtRoot().Length); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(grandchildContainerKey).Length); + Assert.AreEqual(0, GetFolderChildren(greatGrandchildContainerKey).Length); + + childContainer = await ElementContainerService.GetAsync(childContainerKey); + Assert.NotNull(childContainer); + Assert.AreEqual(Constants.System.Root, childContainer.ParentId); + Assert.AreEqual($"{Constants.System.Root},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(1, childContainer.Level); + + grandchildContainer = await ElementContainerService.GetAsync(grandchildContainerKey); + Assert.NotNull(grandchildContainer); + Assert.AreEqual(childContainer.Id, grandchildContainer.ParentId); + Assert.AreEqual($"{childContainer.Path},{grandchildContainer.Id}", grandchildContainer.Path); + Assert.AreEqual(2, grandchildContainer.Level); + + greatGrandchildContainer = await ElementContainerService.GetAsync(greatGrandchildContainerKey); + Assert.NotNull(greatGrandchildContainer); + Assert.AreEqual(grandchildContainer.Id, greatGrandchildContainer.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{greatGrandchildContainer.Id}", greatGrandchildContainer.Path); + Assert.AreEqual(3, greatGrandchildContainer.Level); + } + + [Test] + public async Task Can_Move_Container_With_Descendant_Containers_From_Root_To_Another_Container() + { + var rootContainerKey = Guid.NewGuid(); + var rootContainer = (await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(rootContainer); + + var childContainerKey = Guid.NewGuid(); + var childContainer = (await ElementContainerService.CreateAsync(childContainerKey, "Child Container", null, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(childContainer); + Assert.AreEqual(Constants.System.Root, childContainer.ParentId); + Assert.AreEqual($"{Constants.System.Root},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(1, childContainer.Level); + + var grandchildContainerKey = Guid.NewGuid(); + var grandchildContainer = (await ElementContainerService.CreateAsync(grandchildContainerKey, "Grandchild Container", childContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(grandchildContainer); + Assert.AreEqual(childContainer.Id, grandchildContainer.ParentId); + Assert.AreEqual($"{childContainer.Path},{grandchildContainer.Id}", grandchildContainer.Path); + Assert.AreEqual(2, grandchildContainer.Level); + + var greatGrandchildContainerKey = Guid.NewGuid(); + var greatGrandchildContainer = (await ElementContainerService.CreateAsync(greatGrandchildContainerKey, "Great Grandchild Container", grandchildContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(greatGrandchildContainer); + Assert.AreEqual(grandchildContainer.Id, greatGrandchildContainer.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{greatGrandchildContainer.Id}", greatGrandchildContainer.Path); + Assert.AreEqual(3, greatGrandchildContainer.Level); + + Assert.AreEqual(2, GetAtRoot().Length); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(grandchildContainerKey).Length); + Assert.AreEqual(0, GetFolderChildren(greatGrandchildContainerKey).Length); + + var moveResult = await ElementContainerService.MoveAsync(childContainerKey, rootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.AreEqual(1, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(grandchildContainerKey).Length); + Assert.AreEqual(0, GetFolderChildren(greatGrandchildContainerKey).Length); + + childContainer = await ElementContainerService.GetAsync(childContainerKey); + Assert.NotNull(childContainer); + Assert.AreEqual(rootContainer.Id, childContainer.ParentId); + Assert.AreEqual($"{rootContainer.Path},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(2, childContainer.Level); + + grandchildContainer = await ElementContainerService.GetAsync(grandchildContainerKey); + Assert.NotNull(grandchildContainer); + Assert.AreEqual(childContainer.Id, grandchildContainer.ParentId); + Assert.AreEqual($"{childContainer.Path},{grandchildContainer.Id}", grandchildContainer.Path); + Assert.AreEqual(3, grandchildContainer.Level); + + greatGrandchildContainer = await ElementContainerService.GetAsync(greatGrandchildContainerKey); + Assert.NotNull(greatGrandchildContainer); + Assert.AreEqual(grandchildContainer.Id, greatGrandchildContainer.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{greatGrandchildContainer.Id}", greatGrandchildContainer.Path); + Assert.AreEqual(4, greatGrandchildContainer.Level); + } + + [Test] + public async Task Can_Move_Container_With_Descendant_Containers_From_One_Container_To_Another_Container() + { + var rootContainerKey = Guid.NewGuid(); + var rootContainer = (await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(rootContainer); + + var otherRootContainerKey = Guid.NewGuid(); + var otherRootContainer = (await ElementContainerService.CreateAsync(otherRootContainerKey, "Other Root Container", null, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(otherRootContainer); + + var childContainerKey = Guid.NewGuid(); + var childContainer = (await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(childContainer); + Assert.AreEqual(rootContainer.Id, childContainer.ParentId); + Assert.AreEqual($"{rootContainer.Path},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(2, childContainer.Level); + + var grandchildContainerKey = Guid.NewGuid(); + var grandchildContainer = (await ElementContainerService.CreateAsync(grandchildContainerKey, "Grandchild Container", childContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(grandchildContainer); + Assert.AreEqual(childContainer.Id, grandchildContainer.ParentId); + Assert.AreEqual($"{childContainer.Path},{grandchildContainer.Id}", grandchildContainer.Path); + Assert.AreEqual(3, grandchildContainer.Level); + + var greatGrandchildContainerKey = Guid.NewGuid(); + var greatGrandchildContainer = (await ElementContainerService.CreateAsync(greatGrandchildContainerKey, "Great Grandchild Container", grandchildContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(greatGrandchildContainer); + Assert.AreEqual(grandchildContainer.Id, greatGrandchildContainer.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{greatGrandchildContainer.Id}", greatGrandchildContainer.Path); + Assert.AreEqual(4, greatGrandchildContainer.Level); + + Assert.AreEqual(2, GetAtRoot().Length); + Assert.AreEqual(1, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(0, GetFolderChildren(otherRootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(grandchildContainerKey).Length); + Assert.AreEqual(0, GetFolderChildren(greatGrandchildContainerKey).Length); + + var moveResult = await ElementContainerService.MoveAsync(childContainerKey, otherRootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + Assert.AreEqual(2, GetAtRoot().Length); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(otherRootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(grandchildContainerKey).Length); + Assert.AreEqual(0, GetFolderChildren(greatGrandchildContainerKey).Length); + + childContainer = await ElementContainerService.GetAsync(childContainerKey); + Assert.NotNull(childContainer); + Assert.AreEqual(otherRootContainer.Id, childContainer.ParentId); + Assert.AreEqual($"{otherRootContainer.Path},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(2, childContainer.Level); + + grandchildContainer = await ElementContainerService.GetAsync(grandchildContainerKey); + Assert.NotNull(grandchildContainer); + Assert.AreEqual(childContainer.Id, grandchildContainer.ParentId); + Assert.AreEqual($"{childContainer.Path},{grandchildContainer.Id}", grandchildContainer.Path); + Assert.AreEqual(3, grandchildContainer.Level); + + greatGrandchildContainer = await ElementContainerService.GetAsync(greatGrandchildContainerKey); + Assert.NotNull(greatGrandchildContainer); + Assert.AreEqual(grandchildContainer.Id, greatGrandchildContainer.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{greatGrandchildContainer.Id}", greatGrandchildContainer.Path); + Assert.AreEqual(4, greatGrandchildContainer.Level); + } + + [Test] + [LongRunning] + public async Task Can_Move_Container_With_Descendant_Containers_And_Lots_Of_Elements_From_Root_To_Another_Container() + { + var setup = await CreateContainerWithDescendantContainersAndLotsOfElements(true); + + var moveResult = await ElementContainerService.MoveAsync(setup.ChildContainerKey, setup.RootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + var rootContainer = await ElementContainerService.GetAsync(setup.RootContainerKey); + var childContainer = await ElementContainerService.GetAsync(setup.ChildContainerKey); + Assert.NotNull(rootContainer); + Assert.NotNull(childContainer); + Assert.AreEqual(rootContainer.Id, childContainer.ParentId); + Assert.AreEqual($"{rootContainer.Path},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(2, childContainer.Level); + + var grandchildContainer = await ElementContainerService.GetAsync(setup.GrandchildContainerKey); + Assert.NotNull(grandchildContainer); + Assert.AreEqual(childContainer.Id, grandchildContainer.ParentId); + Assert.AreEqual($"{childContainer.Path},{grandchildContainer.Id}", grandchildContainer.Path); + Assert.AreEqual(3, grandchildContainer.Level); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.AreEqual(1, GetFolderChildren(setup.RootContainerKey).Length); + + var grandchildren = GetFolderChildren(setup.ChildContainerKey); + Assert.AreEqual(setup.ChildContainerItems, grandchildren.Length); + + var greatGrandchildren = GetFolderChildren(setup.GrandchildContainerKey); + Assert.AreEqual(setup.GrandchildContainerItems, greatGrandchildren.Length); + + foreach (var element in grandchildren) + { + Assert.AreEqual(childContainer.Id, element.ParentId); + Assert.AreEqual($"{childContainer.Path},{element.Id}", element.Path); + Assert.AreEqual(3, element.Level); + } + + foreach (var element in greatGrandchildren) + { + Assert.AreEqual(grandchildContainer.Id, element.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{element.Id}", element.Path); + Assert.AreEqual(4, element.Level); + } + } + + [Test] + [LongRunning] + public async Task Can_Move_Container_With_Descendant_Containers_And_Lots_Of_Elements_From_A_Container_To_Root() + { + var setup = await CreateContainerWithDescendantContainersAndLotsOfElements(false); + + var moveResult = await ElementContainerService.MoveAsync(setup.ChildContainerKey, null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + var childContainer = await ElementContainerService.GetAsync(setup.ChildContainerKey); + Assert.NotNull(childContainer); + Assert.AreEqual(Constants.System.Root, childContainer.ParentId); + Assert.AreEqual($"{Constants.System.Root},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(1, childContainer.Level); + + var grandchildContainer = await ElementContainerService.GetAsync(setup.GrandchildContainerKey); + Assert.NotNull(grandchildContainer); + Assert.AreEqual(childContainer.Id, grandchildContainer.ParentId); + Assert.AreEqual($"{childContainer.Path},{grandchildContainer.Id}", grandchildContainer.Path); + Assert.AreEqual(2, grandchildContainer.Level); + + Assert.AreEqual(2, GetAtRoot().Length); + Assert.AreEqual(0, GetFolderChildren(setup.RootContainerKey).Length); + + var grandchildren = GetFolderChildren(setup.ChildContainerKey); + Assert.AreEqual(setup.ChildContainerItems, grandchildren.Length); + + var greatGrandchildren = GetFolderChildren(setup.GrandchildContainerKey); + Assert.AreEqual(setup.GrandchildContainerItems, greatGrandchildren.Length); + + foreach (var element in grandchildren) + { + Assert.AreEqual(childContainer.Id, element.ParentId); + Assert.AreEqual($"{childContainer.Path},{element.Id}", element.Path); + Assert.AreEqual(2, element.Level); + } + + foreach (var element in greatGrandchildren) + { + Assert.AreEqual(grandchildContainer.Id, element.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{element.Id}", element.Path); + Assert.AreEqual(3, element.Level); + } + } + + [Test] + public async Task Can_Move_Container_With_Descendants_From_Recycle_Bin_To_Root() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + + var childContainerKey = Guid.NewGuid(); + var childContainer = (await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(childContainer); + + var elementType = await CreateElementType(); + + var childElement = await CreateElement(elementType.Key, rootContainerKey); + Assert.IsNotNull(childElement); + + var grandchildElement = await CreateElement(elementType.Key, childContainerKey); + Assert.IsNotNull(grandchildElement); + + Assert.AreEqual(2, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + + var moveToRecycleBinResult = await ElementContainerService.MoveToRecycleBinAsync(rootContainerKey, Constants.Security.SuperUserKey); + Assert.IsTrue(moveToRecycleBinResult.Success); + await AssertContainerIsInRecycleBin(rootContainerKey); + + childContainer = await ElementContainerService.GetAsync(childContainerKey); + Assert.IsNotNull(childContainer); + Assert.IsTrue(childContainer.Trashed); + + childElement = await ElementEditingService.GetAsync(childElement.Key); + Assert.IsNotNull(childElement); + Assert.IsTrue(childElement.Trashed); + + grandchildElement = await ElementEditingService.GetAsync(grandchildElement.Key); + Assert.IsNotNull(grandchildElement); + Assert.IsTrue(grandchildElement.Trashed); + + Assert.AreEqual(2, GetFolderChildren(rootContainerKey, true).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey, true).Length); + + var moveResult = await ElementContainerService.MoveAsync(rootContainerKey, null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + var rootContainer = await ElementContainerService.GetAsync(rootContainerKey); + Assert.IsNotNull(rootContainer); + Assert.IsFalse(rootContainer.Trashed); + + childContainer = await ElementContainerService.GetAsync(childContainerKey); + Assert.IsNotNull(childContainer); + Assert.IsFalse(childContainer.Trashed); + + childElement = await ElementEditingService.GetAsync(childElement.Key); + Assert.IsNotNull(childElement); + Assert.IsFalse(childElement.Trashed); + + grandchildElement = await ElementEditingService.GetAsync(grandchildElement.Key); + Assert.IsNotNull(grandchildElement); + Assert.IsFalse(grandchildElement.Trashed); + + Assert.AreEqual(2, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + } + + [Test] + public async Task Restoring_Trashed_Container_Performs_Explicit_Unpublish_Of_All_Descendant_Elements() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + + var childContainerKey = Guid.NewGuid(); + var childContainer = (await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(childContainer); + + var elementType = await CreateElementType(); + + var childElement = await CreateElement(elementType.Key, rootContainerKey); + Assert.IsNotNull(childElement); + + var publishResult = await ElementPublishingService.PublishAsync( + childElement.Key, + [new() { Culture = null }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + var grandchildElement = await CreateElement(elementType.Key, childContainerKey); + Assert.IsNotNull(grandchildElement); + + publishResult = await ElementPublishingService.PublishAsync( + grandchildElement.Key, + [new() { Culture = null }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + var moveToRecycleBinResult = await ElementContainerService.MoveToRecycleBinAsync(rootContainerKey, Constants.Security.SuperUserKey); + Assert.IsTrue(moveToRecycleBinResult.Success); + await AssertContainerIsInRecycleBin(rootContainerKey); + + childElement = await ElementEditingService.GetAsync(childElement.Key); + Assert.NotNull(childElement); + Assert.IsTrue(childElement.Published); + Assert.IsTrue(childElement.Trashed); + + grandchildElement = await ElementEditingService.GetAsync(grandchildElement.Key); + Assert.NotNull(grandchildElement); + Assert.IsTrue(grandchildElement.Published); + Assert.IsTrue(grandchildElement.Trashed); + + var moveResult = await ElementContainerService.MoveAsync(rootContainerKey, null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + childElement = await ElementEditingService.GetAsync(childElement.Key); + Assert.NotNull(childElement); + Assert.IsFalse(childElement.Published); + Assert.IsFalse(childElement.Trashed); + + grandchildElement = await ElementEditingService.GetAsync(grandchildElement.Key); + Assert.NotNull(grandchildElement); + Assert.IsFalse(grandchildElement.Published); + Assert.IsFalse(grandchildElement.Trashed); + } + + [Test] + public async Task Container_Move_Events_Are_Fired() + { + var movingWasCalled = false; + var movedWasCalled = false; + + var firstContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(firstContainerKey, "First Container", null, Constants.Security.SuperUserKey); + + var secondContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(secondContainerKey, "Second Container", null, Constants.Security.SuperUserKey); + + try + { + EntityContainerNotificationHandler.MovingContainer = notification => + { + movingWasCalled = true; + var moveInfo = notification.MoveInfoCollection.Single(); + Assert.AreEqual(secondContainerKey, moveInfo.Entity.Key); + Assert.AreEqual(firstContainerKey, moveInfo.NewParentKey); + }; + + EntityContainerNotificationHandler.MovedContainer = notification => + { + movedWasCalled = true; + var moveInfo = notification.MoveInfoCollection.Single(); + Assert.AreEqual(secondContainerKey, moveInfo.Entity.Key); + Assert.AreEqual(firstContainerKey, moveInfo.NewParentKey); + }; + + var result = await ElementContainerService.MoveAsync(secondContainerKey, firstContainerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + Assert.IsTrue(result.Success); + Assert.IsTrue(movingWasCalled); + Assert.IsTrue(movedWasCalled); + + Assert.AreEqual(1, GetFolderChildren(firstContainerKey).Length); + } + finally + { + EntityContainerNotificationHandler.MovingContainer = null; + EntityContainerNotificationHandler.MovedContainer = null; + } + } + + [Test] + public async Task Container_Moving_Event_Can_Be_Cancelled() + { + var movingWasCalled = false; + var movedWasCalled = false; + + var firstContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(firstContainerKey, "First Container", null, Constants.Security.SuperUserKey); + + var secondContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(secondContainerKey, "Second Container", null, Constants.Security.SuperUserKey); + + try + { + EntityContainerNotificationHandler.MovingContainer = notification => + { + movingWasCalled = true; + var moveInfo = notification.MoveInfoCollection.Single(); + Assert.AreEqual(secondContainerKey, moveInfo.Entity.Key); + Assert.AreEqual(firstContainerKey, moveInfo.NewParentKey); + + notification.Cancel = true; + }; + + EntityContainerNotificationHandler.MovedContainer = _ => + { + movedWasCalled = true; + }; + + var result = await ElementContainerService.MoveAsync(secondContainerKey, firstContainerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(EntityContainerOperationStatus.CancelledByNotification, result.Status); + Assert.IsFalse(result.Success); + Assert.IsTrue(movingWasCalled); + Assert.IsFalse(movedWasCalled); + + Assert.AreEqual(0, GetFolderChildren(firstContainerKey).Length); + } + finally + { + EntityContainerNotificationHandler.MovingContainer = null; + EntityContainerNotificationHandler.MovedContainer = null; + } + } + + [Test] + public async Task Cannot_Move_Container_To_Self() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + + var moveResult = await ElementContainerService.MoveAsync(rootContainerKey, rootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.InvalidParent, moveResult.Status); + }); + } + + [Test] + public async Task Cannot_Move_Container_To_Child_Of_Self() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + + var childContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey); + + var moveResult = await ElementContainerService.MoveAsync(rootContainerKey, childContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.InvalidParent, moveResult.Status); + }); + } + + [Test] + public async Task Cannot_Move_Container_To_Descendant_Of_Self() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + + var childContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey); + + var grandchildContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(grandchildContainerKey, "Grandchild Container", childContainerKey, Constants.Security.SuperUserKey); + + var moveResult = await ElementContainerService.MoveAsync(rootContainerKey, grandchildContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.InvalidParent, moveResult.Status); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.MoveToRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.MoveToRecycleBin.cs new file mode 100644 index 000000000000..7bf887a44f08 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.MoveToRecycleBin.cs @@ -0,0 +1,364 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Attributes; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementContainerServiceTests +{ + [Test] + public async Task Can_Move_Empty_Container_From_Root_To_Recycle_Bin() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + Assert.AreEqual(1, GetAtRoot().Length); + + var moveResult = await ElementContainerService.MoveToRecycleBinAsync(rootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + Assert.AreEqual(0, GetAtRoot().Length); + + await AssertContainerIsInRecycleBin(rootContainerKey); + } + + [Test] + public async Task Can_Move_Empty_Container_From_Another_Container_To_Recycle_Bin() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + + var childContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.AreEqual(1, GetFolderChildren(rootContainerKey).Length); + + var moveResult = await ElementContainerService.MoveToRecycleBinAsync(childContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + + await AssertContainerIsInRecycleBin(childContainerKey); + } + + [Test] + public async Task Can_Move_Container_With_Descendant_Containers_From_Root_To_Recycle_Bin() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + + var childContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey); + + var grandchildContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(grandchildContainerKey, "Grandchild Container", childContainerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.AreEqual(1, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + Assert.AreEqual(0, GetFolderChildren(grandchildContainerKey).Length); + + var moveResult = await ElementContainerService.MoveToRecycleBinAsync(rootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + Assert.AreEqual(0, GetAtRoot().Length); + Assert.AreEqual(1, GetFolderChildren(rootContainerKey, true).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey, true).Length); + Assert.AreEqual(0, GetFolderChildren(grandchildContainerKey, true).Length); + + await AssertContainerIsInRecycleBin(rootContainerKey); + } + + [Test] + public async Task Can_Move_Container_With_Descendant_Containers_From_Another_Container_To_Recycle_Bin() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + + var childContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(childContainerKey, "Child Container", rootContainerKey, Constants.Security.SuperUserKey); + + var grandchildContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(grandchildContainerKey, "Grandchild Container", childContainerKey, Constants.Security.SuperUserKey); + + var greatGrandchildContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(greatGrandchildContainerKey, "Great Grandchild Container", grandchildContainerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.AreEqual(1, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(grandchildContainerKey).Length); + Assert.AreEqual(0, GetFolderChildren(greatGrandchildContainerKey).Length); + + var moveResult = await ElementContainerService.MoveToRecycleBinAsync(childContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(1, GetFolderChildren(childContainerKey, true).Length); + Assert.AreEqual(1, GetFolderChildren(grandchildContainerKey, true).Length); + Assert.AreEqual(0, GetFolderChildren(greatGrandchildContainerKey, true).Length); + + await AssertContainerIsInRecycleBin(childContainerKey); + } + + [TestCase(true)] + [TestCase(false)] + [LongRunning] + public async Task Can_Move_Container_With_Descendant_Containers_And_Lots_Of_Elements_To_Recycle_Bin(bool createChildContainerAtRoot) + { + var setup = await CreateContainerWithDescendantContainersAndLotsOfElements(createChildContainerAtRoot); + + var moveResult = await ElementContainerService.MoveToRecycleBinAsync(setup.ChildContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + await AssertContainerIsInRecycleBin(setup.ChildContainerKey); + + var childContainer = await ElementContainerService.GetAsync(setup.ChildContainerKey); + Assert.NotNull(childContainer); + + var grandchildContainer = await ElementContainerService.GetAsync(setup.GrandchildContainerKey); + Assert.NotNull(grandchildContainer); + Assert.AreEqual(childContainer.Id, grandchildContainer.ParentId); + Assert.AreEqual($"{childContainer.Path},{grandchildContainer.Id}", grandchildContainer.Path); + Assert.AreEqual(2, grandchildContainer.Level); + Assert.IsTrue(grandchildContainer.Trashed); + + Assert.AreEqual(1, GetAtRoot().Length); + Assert.AreEqual(0, GetFolderChildren(setup.RootContainerKey).Length); + + var grandchildren = GetFolderChildren(setup.ChildContainerKey, true); + Assert.AreEqual(setup.ChildContainerItems, grandchildren.Length); + + var greatGrandchildren = GetFolderChildren(setup.GrandchildContainerKey, true); + Assert.AreEqual(setup.GrandchildContainerItems, greatGrandchildren.Length); + + foreach (var element in grandchildren) + { + Assert.AreEqual(childContainer.Id, element.ParentId); + Assert.AreEqual($"{childContainer.Path},{element.Id}", element.Path); + Assert.AreEqual(2, element.Level); + Assert.IsTrue(element.Trashed); + } + + foreach (var element in greatGrandchildren) + { + Assert.AreEqual(grandchildContainer.Id, element.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{element.Id}", element.Path); + Assert.AreEqual(3, element.Level); + Assert.IsTrue(element.Trashed); + } + } + + [Test] + [LongRunning] + public async Task Can_Move_Container_With_Descendant_Containers_And_Lots_Of_Elements_From_Root_To_Recycle_Bin() + { + var setup = await CreateContainerWithDescendantContainersAndLotsOfElements(false); + + var moveResult = await ElementContainerService.MoveToRecycleBinAsync(setup.RootContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, moveResult.Status); + }); + + await AssertContainerIsInRecycleBin(setup.RootContainerKey); + + var rootContainer = await ElementContainerService.GetAsync(setup.RootContainerKey); + Assert.NotNull(rootContainer); + + var childContainer = await ElementContainerService.GetAsync(setup.ChildContainerKey); + Assert.NotNull(childContainer); + Assert.AreEqual(rootContainer.Id, childContainer.ParentId); + Assert.AreEqual($"{rootContainer.Path},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(2, childContainer.Level); + Assert.IsTrue(childContainer.Trashed); + + var grandchildContainer = await ElementContainerService.GetAsync(setup.GrandchildContainerKey); + Assert.NotNull(grandchildContainer); + Assert.AreEqual(childContainer.Id, grandchildContainer.ParentId); + Assert.AreEqual($"{childContainer.Path},{grandchildContainer.Id}", grandchildContainer.Path); + Assert.AreEqual(3, grandchildContainer.Level); + Assert.IsTrue(grandchildContainer.Trashed); + + Assert.AreEqual(0, GetAtRoot().Length); + Assert.AreEqual(1, GetFolderChildren(setup.RootContainerKey, true).Length); + + var grandchildren = GetFolderChildren(setup.ChildContainerKey, true); + Assert.AreEqual(setup.ChildContainerItems, grandchildren.Length); + + var greatGrandchildren = GetFolderChildren(setup.GrandchildContainerKey, true); + Assert.AreEqual(setup.GrandchildContainerItems, greatGrandchildren.Length); + + foreach (var element in grandchildren) + { + Assert.AreEqual(childContainer.Id, element.ParentId); + Assert.AreEqual($"{childContainer.Path},{element.Id}", element.Path); + Assert.AreEqual(3, element.Level); + Assert.IsTrue(element.Trashed); + } + + foreach (var element in greatGrandchildren) + { + Assert.AreEqual(grandchildContainer.Id, element.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{element.Id}", element.Path); + Assert.AreEqual(4, element.Level); + Assert.IsTrue(element.Trashed); + } + } + + [Test] + public async Task Container_Move_To_Recycle_Bin_Events_Are_Fired() + { + var movingWasCalled = false; + var movedWasCalled = false; + + var containerKey = Guid.NewGuid(); + var container = (await ElementContainerService.CreateAsync(containerKey, "The Container", null, Constants.Security.SuperUserKey)).Result; + Assert.IsNotNull(container); + + try + { + EntityContainerNotificationHandler.MovingContainerToRecycleBin = notification => + { + movingWasCalled = true; + var moveInfo = notification.MoveInfoCollection.Single(); + Assert.AreEqual(containerKey, moveInfo.Entity.Key); + Assert.AreEqual(container.Path, moveInfo.OriginalPath); + }; + + EntityContainerNotificationHandler.MovedContainerToRecycleBin = notification => + { + movedWasCalled = true; + var moveInfo = notification.MoveInfoCollection.Single(); + Assert.AreEqual(containerKey, moveInfo.Entity.Key); + Assert.AreEqual(container.Path, moveInfo.OriginalPath); + }; + + var result = await ElementContainerService.MoveToRecycleBinAsync(containerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + Assert.IsTrue(result.Success); + Assert.IsTrue(movingWasCalled); + Assert.IsTrue(movedWasCalled); + + Assert.AreEqual(0, GetAtRoot().Length); + } + finally + { + EntityContainerNotificationHandler.MovingContainerToRecycleBin = null; + EntityContainerNotificationHandler.MovedContainerToRecycleBin = null; + } + } + + [Test] + public async Task Container_Moving_To_Recycle_Bin_Event_Can_Be_Cancelled() + { + var movingWasCalled = false; + var movedWasCalled = false; + + var containerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(containerKey, "The Container", null, Constants.Security.SuperUserKey); + + try + { + EntityContainerNotificationHandler.MovingContainerToRecycleBin = notification => + { + movingWasCalled = true; + notification.Cancel = true; + }; + + EntityContainerNotificationHandler.MovedContainerToRecycleBin = _ => + { + movedWasCalled = true; + }; + + var result = await ElementContainerService.MoveToRecycleBinAsync(containerKey, Constants.Security.SuperUserKey); + + Assert.AreEqual(EntityContainerOperationStatus.CancelledByNotification, result.Status); + Assert.IsFalse(result.Success); + Assert.IsTrue(movingWasCalled); + Assert.IsFalse(movedWasCalled); + + Assert.AreEqual(1, GetAtRoot().Length); + } + finally + { + EntityContainerNotificationHandler.MovingContainerToRecycleBin = null; + EntityContainerNotificationHandler.MovedContainerToRecycleBin = null; + } + } + + [Test] + public async Task Cannot_Move_Container_To_Container_In_Recycle_Bin() + { + var rootContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + + var trashedContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(trashedContainerKey, "Trashed Container", null, Constants.Security.SuperUserKey); + await ElementContainerService.MoveToRecycleBinAsync(trashedContainerKey, Constants.Security.SuperUserKey); + + var moveResult = await ElementContainerService.MoveAsync(rootContainerKey, trashedContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.InTrash, moveResult.Status); + }); + + var rootContainer = await ElementContainerService.GetAsync(rootContainerKey); + Assert.IsNotNull(rootContainer); + Assert.IsFalse(rootContainer.Trashed); + + Assert.AreEqual(0, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(0, GetFolderChildren(trashedContainerKey, true).Length); + } + + [Test] + public async Task Cannot_Move_Container_In_Recycle_Bin_To_Other_Container_In_Recycle_Bin() + { + var firstContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(firstContainerKey, "First Container", null, Constants.Security.SuperUserKey); + await ElementContainerService.MoveToRecycleBinAsync(firstContainerKey, Constants.Security.SuperUserKey); + + var secondContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(secondContainerKey, "Second Container", null, Constants.Security.SuperUserKey); + await ElementContainerService.MoveToRecycleBinAsync(secondContainerKey, Constants.Security.SuperUserKey); + + var moveResult = await ElementContainerService.MoveAsync(firstContainerKey, secondContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.InTrash, moveResult.Status); + }); + + var firstContainer = await ElementContainerService.GetAsync(firstContainerKey); + Assert.IsNotNull(firstContainer); + Assert.IsTrue(firstContainer.Trashed); + Assert.AreEqual(Constants.System.RecycleBinElement, firstContainer.ParentId); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Update.cs new file mode 100644 index 000000000000..642ef3b56092 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.Update.cs @@ -0,0 +1,52 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementContainerServiceTests +{ + [Test] + public async Task Can_Update_Container_At_Root() + { + var key = (await ElementContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey)).Result.Key; + + var result = await ElementContainerService.UpdateAsync(key, "Root Container UPDATED", Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + var updated = await ElementContainerService.GetAsync(key); + Assert.NotNull(updated); + Assert.Multiple(() => + { + Assert.AreEqual("Root Container UPDATED", updated.Name); + Assert.AreEqual(Constants.System.Root, updated.ParentId); + }); + } + + [Test] + public async Task Can_Update_Child_Container() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey)).Result; + EntityContainer child = (await ElementContainerService.CreateAsync(null, "Child Container", root.Key, Constants.Security.SuperUserKey)).Result; + + var result = await ElementContainerService.UpdateAsync(child.Key, "Child Container UPDATED", Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + EntityContainer updated = await ElementContainerService.GetAsync(child.Key); + Assert.NotNull(updated); + Assert.Multiple(() => + { + Assert.AreEqual("Child Container UPDATED", updated.Name); + Assert.AreEqual(root.Id, updated.ParentId); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.cs new file mode 100644 index 000000000000..b3e9e7f2ece2 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.cs @@ -0,0 +1,199 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Notifications; +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.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public partial class ElementContainerServiceTests : UmbracoIntegrationTest +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IElementPublishingService ElementPublishingService => GetRequiredService(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler(); + + private IEntitySlim[] GetAtRoot() + => EntityService.GetRootEntities(UmbracoObjectTypes.ElementContainer).Union(EntityService.GetRootEntities(UmbracoObjectTypes.Element)).ToArray(); + + private IEntitySlim[] GetFolderChildren(Guid containerKey, bool trashed = false) + => EntityService.GetPagedChildren(containerKey, [UmbracoObjectTypes.ElementContainer], [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element], 0, 999, trashed, out _).ToArray(); + + private async Task CreateElementType() + { + var elementType = new ContentTypeBuilder() + .WithAlias("test") + .WithName("Test") + .WithAllowAsRoot(true) + .WithIsElement(true) + .Build(); + + var result = await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + Assert.AreEqual(true, result.Success); + return elementType; + } + + private async Task CreateElement(Guid contentTypeKey, Guid? parentKey = null) + { + var createModel = new ElementCreateModel + { + ContentTypeKey = contentTypeKey, + ParentKey = parentKey, + Variants = + [ + new VariantModel { Name = Guid.NewGuid().ToString("N") } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content!; + } + + private async Task CreateContainerWithDescendantContainersAndLotsOfElements(bool createChildContainerAtRoot) + { + var rootContainerKey = Guid.NewGuid(); + var rootContainer = (await ElementContainerService.CreateAsync(rootContainerKey, "Root Container", null, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(rootContainer); + + var childContainerKey = Guid.NewGuid(); + var childContainer = (await ElementContainerService.CreateAsync(childContainerKey, "Child Container", createChildContainerAtRoot ? null : rootContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(childContainer); + Assert.AreEqual(createChildContainerAtRoot ? Constants.System.Root : rootContainer.Id, childContainer.ParentId); + Assert.AreEqual($"{(createChildContainerAtRoot ? Constants.System.Root : rootContainer.Path)},{childContainer.Id}", childContainer.Path); + Assert.AreEqual(createChildContainerAtRoot ? 1 : 2, childContainer.Level); + + var grandchildContainerKey = Guid.NewGuid(); + var grandchildContainer = (await ElementContainerService.CreateAsync(grandchildContainerKey, "Grandchild Container", childContainerKey, Constants.Security.SuperUserKey)).Result; + Assert.NotNull(grandchildContainer); + + var elementType = await CreateElementType(); + + // ensure that we have at least three pages of descendants to iterate across + var iterations = Cms.Core.Services.ElementContainerService.DescendantsIteratorPageSize + 5; + for (var i = 0; i < iterations; i++) + { + var element = await CreateElement(elementType.Key, childContainerKey); + Assert.AreEqual(childContainer.Id, element.ParentId); + Assert.AreEqual($"{childContainer.Path},{element.Id}", element.Path); + Assert.AreEqual(childContainer.Level + 1, element.Level); + + element = await CreateElement(elementType.Key, grandchildContainerKey); + Assert.AreEqual(grandchildContainer.Id, element.ParentId); + Assert.AreEqual($"{grandchildContainer.Path},{element.Id}", element.Path); + Assert.AreEqual(grandchildContainer.Level + 1, element.Level); + } + + Assert.AreEqual(createChildContainerAtRoot ? 2 : 1, GetAtRoot().Length); + Assert.AreEqual(createChildContainerAtRoot ? 0 : 1, GetFolderChildren(rootContainerKey).Length); + Assert.AreEqual(506, GetFolderChildren(childContainerKey).Length); + Assert.AreEqual(505, GetFolderChildren(grandchildContainerKey).Length); + + return new() + { + RootContainerKey = rootContainerKey, + ChildContainerKey = childContainerKey, + GrandchildContainerKey = grandchildContainerKey, + RootItems = createChildContainerAtRoot ? 2 : 1, + RootContainerItems = createChildContainerAtRoot ? 0 : 1, + ChildContainerItems = 506, + GrandchildContainerItems = 505, + }; + } + + private async Task AssertContainerIsInRecycleBin(Guid containerKey) + { + var container = await ElementContainerService.GetAsync(containerKey); + Assert.NotNull(container); + Assert.Multiple(() => + { + Assert.AreEqual(Constants.System.RecycleBinElement, container.ParentId); + Assert.AreEqual($"{Constants.System.RecycleBinElementPathPrefix}{container.Id}", container.Path); + Assert.IsTrue(container.Trashed); + }); + + var recycleBinItems = EntityService + .GetPagedChildren(Constants.System.RecycleBinElementKey, [UmbracoObjectTypes.ElementContainer], [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element], 0, 999, true, out var total) + .ToArray(); + + Assert.Multiple(() => + { + Assert.AreEqual(1, total); + Assert.AreEqual(1, recycleBinItems.Length); + }); + + Assert.AreEqual(container.Key, recycleBinItems[0].Key); + } + + private struct FolderWithElementsStructureInfo + { + public Guid RootContainerKey { get; init; } + + public Guid ChildContainerKey { get; init; } + + public Guid GrandchildContainerKey { get; init; } + + public int RootItems { get; init; } + + public int RootContainerItems { get; init; } + + public int ChildContainerItems { get; init; } + + public int GrandchildContainerItems { get; init; } + } + + private sealed class EntityContainerNotificationHandler : + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler + { + public static Action? MovingContainer { get; set; } + + public static Action? MovedContainer { get; set; } + + public static Action? MovingContainerToRecycleBin { get; set; } + + public static Action? MovedContainerToRecycleBin { get; set; } + + public static Action? DeletingContainer { get; set; } + + public static Action? DeletedContainer { get; set; } + + public void Handle(EntityContainerMovingNotification notification) => MovingContainer?.Invoke(notification); + + public void Handle(EntityContainerMovedNotification notification) => MovedContainer?.Invoke(notification); + + public void Handle(EntityContainerMovingToRecycleBinNotification notification) => MovingContainerToRecycleBin?.Invoke(notification); + + public void Handle(EntityContainerMovedToRecycleBinNotification notification) => MovedContainerToRecycleBin?.Invoke(notification); + + public void Handle(EntityContainerDeletingNotification notification) => DeletingContainer?.Invoke(notification); + + public void Handle(EntityContainerDeletedNotification notification) => DeletedContainer?.Invoke(notification); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Copy.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Copy.cs new file mode 100644 index 000000000000..df86a7c8a1e6 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Copy.cs @@ -0,0 +1,105 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementEditingServiceTests +{ + [Test] + public async Task Can_Copy_To_Root() + { + var container = (await ElementContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey)).Result; + var original = await CreateInvariantElement(container.Key); + + Assert.AreEqual(1, GetFolderChildren(container.Key).Length); + + var copyResult = await ElementEditingService.CopyAsync(original.Key, null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(copyResult.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, copyResult.Status); + }); + + var copy = copyResult.Result; + Assert.IsNotNull(copy); + Assert.Multiple(() => + { + Assert.IsTrue(copy.HasIdentity); + Assert.AreEqual(Constants.System.Root, copy.ParentId); + Assert.AreNotEqual(original.Key, copy.Key); + Assert.AreEqual(original.Name, copy.Name); + }); + } + + [Test] + public async Task Can_Copy_To_Another_Parent() + { + var container1 = (await ElementContainerService.CreateAsync(null, "Root Container 1", null, Constants.Security.SuperUserKey)).Result; + var container2 = (await ElementContainerService.CreateAsync(null, "Root Container 2", null, Constants.Security.SuperUserKey)).Result; + var original = await CreateInvariantElement(container1.Key); + + Assert.AreEqual(1, GetFolderChildren(container1.Key).Length); + Assert.AreEqual(0, GetFolderChildren(container2.Key).Length); + + var copyResult = await ElementEditingService.CopyAsync(original.Key, container2.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(copyResult.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, copyResult.Status); + }); + + Assert.AreEqual(1, GetFolderChildren(container2.Key).Length); + + var copy = copyResult.Result; + Assert.IsNotNull(copy); + Assert.Multiple(() => + { + Assert.IsTrue(copy.HasIdentity); + Assert.AreEqual(container2.Id, copy.ParentId); + Assert.AreNotEqual(original.Key, copy.Key); + Assert.AreEqual(original.Name, copy.Name); + }); + } + + [Test] + public async Task Can_Copy_To_Existing_Parent() + { + var container = (await ElementContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey)).Result; + var original = await CreateInvariantElement(container.Key); + + Assert.AreEqual(1, GetFolderChildren(container.Key).Length); + + var copyResult = await ElementEditingService.CopyAsync(original.Key, container.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(copyResult.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, copyResult.Status); + }); + + Assert.AreEqual(2, GetFolderChildren(container.Key).Length); + + var copy = copyResult.Result; + Assert.IsNotNull(copy); + Assert.Multiple(() => + { + Assert.IsTrue(copy.HasIdentity); + Assert.AreEqual(container.Id, copy.ParentId); + Assert.AreNotEqual(original.Key, copy.Key); + Assert.AreEqual($"{original.Name} (1)", copy.Name); + }); + } + + [Test] + public async Task Cannot_Copy_To_Non_Existing_Parent() + { + var original = await CreateInvariantElement(); + + var copyResult = await ElementEditingService.CopyAsync(original.Key, Guid.NewGuid(), Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(copyResult.Success); + Assert.AreEqual(ContentEditingOperationStatus.ParentNotFound, copyResult.Status); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Create.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Create.cs new file mode 100644 index 000000000000..90821dca654a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Create.cs @@ -0,0 +1,473 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementEditingServiceTests +{ + [Test] + public async Task Can_Create_At_Root() + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title value" }, + new PropertyValueModel { Alias = "text", Value = "The text value" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyCreate(result.Result.Content); + + // re-get and re-test + VerifyCreate(await ElementEditingService.GetAsync(result.Result.Content!.Key)); + + void VerifyCreate(IElement? createdElement) + { + Assert.IsNotNull(createdElement); + Assert.AreNotEqual(Guid.Empty, createdElement.Key); + Assert.IsTrue(createdElement.HasIdentity); + Assert.AreEqual("Test Create", createdElement.Name); + Assert.AreEqual("The title value", createdElement.GetValue("title")); + Assert.AreEqual("The text value", createdElement.GetValue("text")); + } + } + + [Test] + public async Task Can_Create_In_A_Folder() + { + var elementType = await CreateInvariantElementType(); + + var containerKey = Guid.NewGuid(); + var container = (await ElementContainerService.CreateAsync(containerKey, "Root Container", null, Constants.Security.SuperUserKey)).Result; + + var elementKey = Guid.NewGuid(); + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = containerKey, + Key = elementKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title value" }, + new PropertyValueModel { Alias = "text", Value = "The text value" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + var element = await ElementEditingService.GetAsync(elementKey); + Assert.NotNull(element); + Assert.AreEqual(container.Id, element.ParentId); + + var children = GetFolderChildren(containerKey); + Assert.AreEqual(1, children.Length); + Assert.AreEqual(elementKey, children[0].Key); + } + + [Test] + public async Task Can_Create_Without_Properties() + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsTrue(result.Result.Content!.HasIdentity); + Assert.AreEqual(null, result.Result.Content!.GetValue("title")); + Assert.AreEqual(null, result.Result.Content!.GetValue("text")); + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Create_With_Property_Validation(bool addValidProperties) + { + var elementType = await CreateInvariantElementType(); + elementType.PropertyTypes.First(pt => pt.Alias == "title").Mandatory = true; + elementType.PropertyTypes.First(pt => pt.Alias == "text").ValidationRegExp = "^\\d*$"; + elementType.AllowedAsRoot = true; + await ContentTypeService.UpdateAsync(elementType, Constants.Security.SuperUserKey); + + var titleValue = addValidProperties ? "The title value" : null; + var textValue = addValidProperties ? "12345" : "This is not a number"; + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = new[] + { + new PropertyValueModel { Alias = "title", Value = titleValue }, + new PropertyValueModel { Alias = "text", Value = textValue } + }, + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + + // success is expected regardless of property level validation - the validation error status is communicated in the attempt status (see below) + Assert.IsTrue(result.Success); + Assert.AreEqual(addValidProperties ? ContentEditingOperationStatus.Success : ContentEditingOperationStatus.PropertyValidationError, result.Status); + Assert.IsNotNull(result.Result); + + if (addValidProperties is false) + { + Assert.AreEqual(2, result.Result.ValidationResult.ValidationErrors.Count()); + Assert.IsNotNull(result.Result.ValidationResult.ValidationErrors.FirstOrDefault(v => v.Alias == "title" && v.ErrorMessages.Length == 1)); + Assert.IsNotNull(result.Result.ValidationResult.ValidationErrors.FirstOrDefault(v => v.Alias == "text" && v.ErrorMessages.Length == 1)); + } + + // NOTE: creation must be successful, even if the mandatory property is missing (publishing however should not!) + Assert.IsTrue(result.Result.Content!.HasIdentity); + Assert.AreEqual(titleValue, result.Result.Content!.GetValue("title")); + Assert.AreEqual(textValue, result.Result.Content!.GetValue("text")); + } + + [Test] + public async Task Can_Create_With_Explicit_Key() + { + var elementType = await CreateInvariantElementType(); + + var key = Guid.NewGuid(); + var createModel = new ElementCreateModel + { + Key = key, + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result.Content); + Assert.IsTrue(result.Result.Content.HasIdentity); + Assert.AreEqual(key, result.Result.Content.Key); + + var element = await ElementEditingService.GetAsync(key); + Assert.IsNotNull(element); + Assert.AreEqual(result.Result.Content.Id, element.Id); + } + + [Test] + public async Task Can_Create_Culture_Variant() + { + var elementType = await CreateVariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The Invariant Title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The English Title", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Danish Title", Culture = "da-DK" } + ], + Variants = + [ + new VariantModel { Culture = "en-US", Name = "The English Name" }, + new VariantModel { Culture = "da-DK", Name = "The Danish Name" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result.Content); + VerifyCreate(result.Result.Content); + + // re-get and re-test + VerifyCreate(await ElementEditingService.GetAsync(result.Result.Content.Key)); + + void VerifyCreate(IElement? createdElement) + { + Assert.IsNotNull(createdElement); + Assert.AreEqual("The English Name", createdElement.GetCultureName("en-US")); + Assert.AreEqual("The Danish Name", createdElement.GetCultureName("da-DK")); + Assert.AreEqual("The Invariant Title", createdElement.GetValue("invariantTitle")); + Assert.AreEqual("The English Title", createdElement.GetValue("variantTitle", "en-US")); + Assert.AreEqual("The Danish Title", createdElement.GetValue("variantTitle", "da-DK")); + } + } + + [Test] + public async Task Can_Create_Segment_Variant() + { + var elementType = await CreateVariantElementType(ContentVariation.Segment); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The Invariant Title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Default Title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-1 Title", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-2 Title", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Name = "The Name" }, + new VariantModel { Segment = "seg-1", Name = "The Name" }, + new VariantModel { Segment = "seg-2", Name = "The Name" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result.Content); + VerifyCreate(result.Result.Content); + + // re-get and re-test + VerifyCreate(await ElementEditingService.GetAsync(result.Result.Content.Key)); + + void VerifyCreate(IElement? createdElement) + { + Assert.IsNotNull(createdElement); + Assert.Multiple(() => + { + Assert.AreEqual("The Name", createdElement.Name); + Assert.AreEqual("The Invariant Title", createdElement.GetValue("invariantTitle")); + Assert.AreEqual("The Default Title", createdElement.GetValue("variantTitle", segment: null)); + Assert.AreEqual("The Seg-1 Title", createdElement.GetValue("variantTitle", segment: "seg-1")); + Assert.AreEqual("The Seg-2 Title", createdElement.GetValue("variantTitle", segment: "seg-2")); + }); + } + } + + [Test] + public async Task Can_Create_Culture_And_Segment_Variant() + { + var elementType = await CreateVariantElementType(ContentVariation.CultureAndSegment); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The Invariant Title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Default Title in English", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-1 Title in English", Culture = "en-US", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-2 Title in English", Culture = "en-US", Segment = "seg-2" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Default Title in Danish", Culture = "da-DK" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-1 Title in Danish", Culture = "da-DK", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-2 Title in Danish", Culture = "da-DK", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Name = "The English Name", Culture = "en-US" }, + new VariantModel { Name = "The English Name", Culture = "en-US", Segment = "seg-1" }, + new VariantModel { Name = "The English Name", Culture = "en-US", Segment = "seg-2" }, + new VariantModel { Name = "The Danish Name", Culture = "da-DK" }, + new VariantModel { Name = "The Danish Name", Culture = "da-DK", Segment = "seg-1" }, + new VariantModel { Name = "The Danish Name", Culture = "da-DK", Segment = "seg-2" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result.Content); + VerifyCreate(result.Result.Content); + + // re-get and re-test + VerifyCreate(await ElementEditingService.GetAsync(result.Result.Content.Key)); + + void VerifyCreate(IElement? createdElement) + { + Assert.IsNotNull(createdElement); + Assert.Multiple(() => + { + Assert.AreEqual("The English Name", createdElement.GetCultureName("en-US")); + Assert.AreEqual("The Danish Name", createdElement.GetCultureName("da-DK")); + Assert.AreEqual("The Invariant Title", createdElement.GetValue("invariantTitle")); + Assert.AreEqual("The Default Title in English", createdElement.GetValue("variantTitle", culture: "en-US", segment: null)); + Assert.AreEqual("The Seg-1 Title in English", createdElement.GetValue("variantTitle", culture: "en-US", segment: "seg-1")); + Assert.AreEqual("The Seg-2 Title in English", createdElement.GetValue("variantTitle", culture: "en-US", segment: "seg-2")); + Assert.AreEqual("The Default Title in Danish", createdElement.GetValue("variantTitle", culture: "da-DK", segment: null)); + Assert.AreEqual("The Seg-1 Title in Danish", createdElement.GetValue("variantTitle", culture: "da-DK", segment: "seg-1")); + Assert.AreEqual("The Seg-2 Title in Danish", createdElement.GetValue("variantTitle", culture: "da-DK", segment: "seg-2")); + }); + } + } + + [Test] + public async Task Cannot_Create_Without_Element_Type() + { + var createModel = new ElementCreateModel + { + ContentTypeKey = Guid.NewGuid(), + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ContentTypeNotFound, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); + } + + [Test] + public async Task Cannot_Create_With_Non_Existing_Properties() + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title value" }, + new PropertyValueModel { Alias = "no_such_property", Value = "No such property value" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.PropertyTypeNotFound, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); + } + + [Test] + public async Task Cannot_Create_Invariant_Element_Without_Name() + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = [], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title value" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); + } + + [TestCase(ContentVariation.Culture)] + [TestCase(ContentVariation.Segment)] + public async Task Cannot_Create_With_Variant_Property_Value_For_Invariant_Content(ContentVariation contentVariation) + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = + [ + new PropertyValueModel + { + Alias = "title", + Value = "The title value", + }, + new PropertyValueModel + { + Alias = "bodyText", + Value = "The body text value", + Culture = contentVariation is ContentVariation.Culture ? "en-US" : null, + Segment = contentVariation is ContentVariation.Segment ? "segment" : null, + } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.PropertyTypeNotFound, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); + } + + [Test] + [Ignore("We will get around to fixing this as part of the general Elements clean-up task.", Until = "2026-03-31")] + // TODO ELEMENTS: make ContentEditingServiceBase element aware so it can guard against this test case + // TODO ELEMENTS: create a similar test for content creation based on element types + public async Task Cannot_Create_Element_Based_On_NonElement_ContentType() + { + var contentType = ContentTypeBuilder.CreateSimpleContentType(); + Assert.IsFalse(contentType.IsElement); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var createModel = new ElementCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title value" }, + new PropertyValueModel { Alias = "bodyText", Value = "The body text" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotAllowed, result.Status); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Delete.cs new file mode 100644 index 000000000000..e0944ae19e3b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Delete.cs @@ -0,0 +1,84 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementEditingServiceTests +{ + [TestCase(true)] + [TestCase(false)] + public async Task Can_Delete_FromOutsideOfRecycleBin(bool variant) + { + var element = await (variant ? CreateCultureVariantElement() : CreateInvariantElement()); + + var result = await ElementEditingService.DeleteAsync(element.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + // re-get and verify deletion + element = await ElementEditingService.GetAsync(element.Key); + Assert.IsNull(element); + } + + [Test] + public async Task Can_Delete_FromRecycleBin() + { + var element = await CreateInvariantElement(); + await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + var result = await ElementEditingService.DeleteAsync(element.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + // re-get and verify deletion + element = await ElementEditingService.GetAsync(element.Key); + Assert.IsNull(element); + } + + [Test] + public async Task Cannot_Delete_Non_Existing() + { + var result = await ElementEditingService.DeleteAsync(Guid.NewGuid(), Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotFound, result.Status); + } + + [Test] + public async Task Deleting_Element_Type_Deletes_All_Elements_Of_That_Type() + { + var elementType = await CreateInvariantElementType(); + + for (var i = 0; i < 10; i++) + { + var key = Guid.NewGuid(); + await ElementEditingService.CreateAsync( + new ElementCreateModel + { + Key = key, + ContentTypeKey = elementType.Key, + ParentKey = null, + Variants = [new() { Name = $"Name {i}" }], + }, + Constants.Security.SuperUserKey); + + if (i % 2 == 0) + { + // move half of the created elements to trash, to ensure that also trashed elements are deleted + // when deleting the element type + await ElementEditingService.MoveToRecycleBinAsync(key, Constants.Security.SuperUserKey); + } + } + + Assert.AreEqual(5, EntityService.GetRootEntities(UmbracoObjectTypes.Element).Count()); + Assert.AreEqual(5, EntityService.GetPagedTrashedChildren(Constants.System.RecycleBinElementKey, UmbracoObjectTypes.Element, 0, 100, out _).Count()); + + var result = await ContentTypeService.DeleteAsync(elementType.Key, Constants.Security.SuperUserKey); + Assert.AreEqual(ContentTypeOperationStatus.Success, result); + + Assert.AreEqual(0, EntityService.GetRootEntities(UmbracoObjectTypes.Element).Count()); + Assert.AreEqual(0, EntityService.GetPagedTrashedChildren(Constants.System.RecycleBinElementKey, UmbracoObjectTypes.Element, 0, 100, out _).Count()); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.DeleteFromRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.DeleteFromRecycleBin.cs new file mode 100644 index 000000000000..59e47deb2659 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.DeleteFromRecycleBin.cs @@ -0,0 +1,46 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementEditingServiceTests +{ + [TestCase(true)] + [TestCase(false)] + public async Task Can_DeleteRecycleBin_FromRecycleBin(bool variant) + { + var element = await (variant ? CreateCultureVariantElement() : CreateInvariantElement()); + await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + var result = await ElementEditingService.DeleteFromRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + // re-get and verify deletion + element = await ElementEditingService.GetAsync(element.Key); + Assert.IsNull(element); + } + + [Test] + public async Task Cannot_DeleteRecycleBin_FromOutsideOfRecycleBin() + { + var element = await CreateInvariantElement(); + + var result = await ElementEditingService.DeleteFromRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotInTrash, result.Status); + + // re-get and verify that deletion failed + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + } + + [Test] + public async Task Cannot_DeleteRecycleBin_Non_Existing() + { + var result = await ElementEditingService.DeleteFromRecycleBinAsync(Guid.NewGuid(), Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotFound, result.Status); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Move.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Move.cs new file mode 100644 index 000000000000..e329d4421dd4 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Move.cs @@ -0,0 +1,308 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementEditingServiceTests +{ + [Test] + public async Task Can_Move_Element_From_Root_To_A_Folder() + { + var containerKey = Guid.NewGuid(); + var container = (await ElementContainerService.CreateAsync(containerKey, "Root Container", null, Constants.Security.SuperUserKey)).Result; + Assert.AreEqual(0, GetFolderChildren(containerKey).Length); + + var element = await CreateInvariantElement(); + + var moveResult = await ElementEditingService.MoveAsync(element.Key, containerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, moveResult.Result); + }); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.Multiple(() => + { + Assert.AreEqual(container.Id, element.ParentId); + Assert.AreEqual($"{container.Path},{element.Id}", element.Path); + }); + + var result = GetFolderChildren(containerKey); + Assert.AreEqual(1, result.Length); + Assert.AreEqual(element.Key, result.First().Key); + } + + [Test] + public async Task Can_Move_Element_From_A_Folder_To_Root() + { + var containerKey = Guid.NewGuid(); + var container = (await ElementContainerService.CreateAsync(containerKey, "Root Container", null, Constants.Security.SuperUserKey)).Result; + + var element = await CreateInvariantElement(containerKey); + Assert.AreEqual(container.Id, element.ParentId); + Assert.AreEqual(1, GetFolderChildren(containerKey).Length); + + var moveResult = await ElementEditingService.MoveAsync(element.Key, null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, moveResult.Result); + }); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.Multiple(() => + { + Assert.AreEqual(Constants.System.Root, element.ParentId); + Assert.AreEqual($"{Constants.System.Root},{element.Id}", element.Path); + }); + + Assert.AreEqual(0, GetFolderChildren(containerKey).Length); + } + + [Test] + public async Task Can_Move_Element_Between_Folders() + { + var containerKey1 = Guid.NewGuid(); + await ElementContainerService.CreateAsync(containerKey1, "Container #1", null, Constants.Security.SuperUserKey); + var containerKey2 = Guid.NewGuid(); + var container2 = (await ElementContainerService.CreateAsync(containerKey2, "Container #2", null, Constants.Security.SuperUserKey)).Result; + + var element = await CreateInvariantElement(containerKey1); + Assert.AreEqual(1, GetFolderChildren(containerKey1).Length); + + await ElementEditingService.MoveAsync(element.Key, containerKey2, Constants.Security.SuperUserKey); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.Multiple(() => + { + Assert.AreEqual(container2.Id, element.ParentId); + Assert.AreEqual($"{container2.Path},{element.Id}", element.Path); + }); + + var result = GetFolderChildren(containerKey2); + Assert.AreEqual(1, result.Length); + Assert.AreEqual(element.Key, result.First().Key); + + Assert.AreEqual(0, GetFolderChildren(containerKey1).Length); + } + + [Test] + public async Task Can_Move_Trashed_Element_To_Root() + { + var element = await CreateInvariantElement(); + + var moveResult = await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveResult.Success); + + moveResult = await ElementEditingService.MoveAsync(element.Key, null, Constants.Security.SuperUserKey); + Assert.IsTrue(moveResult.Success); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.Multiple(() => + { + Assert.IsFalse(element.Trashed); + Assert.AreEqual(Constants.System.Root, element.ParentId); + Assert.AreEqual($"{Constants.System.Root},{element.Id}", element.Path); + }); + } + + [Test] + public async Task Can_Move_Trashed_Element_To_A_Folder() + { + var containerKey1 = Guid.NewGuid(); + var container1 = (await ElementContainerService.CreateAsync(containerKey1, "Container #1", null, Constants.Security.SuperUserKey)).Result; + + var element = await CreateInvariantElement(); + + var moveResult = await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveResult.Success); + + moveResult = await ElementEditingService.MoveAsync(element.Key, containerKey1, Constants.Security.SuperUserKey); + Assert.IsTrue(moveResult.Success); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.Multiple(() => + { + Assert.IsFalse(element.Trashed); + Assert.AreEqual(container1.Id, element.ParentId); + Assert.AreEqual($"{container1.Path},{element.Id}", element.Path); + }); + } + + [Test] + public async Task Restoring_Trashed_Published_Invariant_Element_Performs_Explicit_Unpublish() + { + var element = await CreateInvariantElement(); + + var publishResult = await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = "*" }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + var moveResult = await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveResult.Success); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.IsTrue(element.Published); + Assert.IsTrue(element.Trashed); + + moveResult = await ElementEditingService.MoveAsync(element.Key, null, Constants.Security.SuperUserKey); + Assert.IsTrue(moveResult.Success); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.IsFalse(element.Published); + Assert.IsFalse(element.Trashed); + } + + [TestCase("en-US", "da-DK")] + [TestCase("en-US")] + [TestCase("da-DK")] + public async Task Restoring_Trashed_Published_Variant_Element_Performs_Explicit_Unpublish(params string[] publishedCultures) + { + var elementType = await CreateVariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The Invariant Title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The English Title", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Danish Title", Culture = "da-DK" } + ], + Variants = + [ + new VariantModel { Culture = "en-US", Name = "The English Name" }, + new VariantModel { Culture = "da-DK", Name = "The Danish Name" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + var element = result.Result.Content!; + + var culturePublishScheduleModels = publishedCultures + .Select(culture => new CulturePublishScheduleModel { Culture = culture }) + .ToArray(); + var publishResult = await ElementPublishingService.PublishAsync(element.Key, + culturePublishScheduleModels, + Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + var moveResult = await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveResult.Success); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.IsTrue(element.Published); + CollectionAssert.AreEquivalent(publishedCultures, element.PublishedCultures); + Assert.IsTrue(element.Trashed); + + moveResult = await ElementEditingService.MoveAsync(element.Key, null, Constants.Security.SuperUserKey); + Assert.IsTrue(moveResult.Success); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.IsFalse(element.Published); + Assert.IsEmpty(element.PublishedCultures); + Assert.IsFalse(element.Trashed); + } + + [Test] + public async Task Can_Cancel_Unpublishing_When_Restoring_Trashed_Published_Element() + { + var element = await CreateInvariantElement(); + + var publishResult = await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = "*" }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + var moveResult = await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveResult.Success); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.IsTrue(element.Published); + Assert.IsTrue(element.Trashed); + + try + { + ElementNotificationHandler.UnpublishingElement = (notification) => notification.Cancel = true; + + moveResult = await ElementEditingService.MoveAsync(element.Key, null, Constants.Security.SuperUserKey); + Assert.IsTrue(moveResult.Success); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element); + Assert.IsTrue(element.Published); + Assert.IsFalse(element.Trashed); + } + finally + { + ElementNotificationHandler.UnpublishingElement = null; + } + } + + [Test] + public async Task Cannot_Move_Element_To_Container_In_Recycle_Bin() + { + var element = await CreateInvariantElement(); + + var trashedContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(trashedContainerKey, "Trashed Container", null, Constants.Security.SuperUserKey); + await ElementContainerService.MoveToRecycleBinAsync(trashedContainerKey, Constants.Security.SuperUserKey); + + var moveResult = await ElementContainerService.MoveAsync(element.Key, trashedContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.InTrash, moveResult.Status); + }); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.IsNotNull(element); + Assert.IsFalse(element.Trashed); + + Assert.AreEqual(0, GetFolderChildren(trashedContainerKey, true).Length); + } + + [Test] + public async Task Cannot_Move_Element_In_Recycle_Bin_To_Container_In_Recycle_Bin() + { + var element = await CreateInvariantElement(); + await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + var trashedContainerKey = Guid.NewGuid(); + await ElementContainerService.CreateAsync(trashedContainerKey, "Trashed Container", null, Constants.Security.SuperUserKey); + await ElementContainerService.MoveToRecycleBinAsync(trashedContainerKey, Constants.Security.SuperUserKey); + + var moveResult = await ElementContainerService.MoveAsync(element.Key, trashedContainerKey, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(moveResult.Success); + Assert.AreEqual(EntityContainerOperationStatus.InTrash, moveResult.Status); + }); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.IsNotNull(element); + Assert.IsTrue(element.Trashed); + + Assert.AreEqual(0, GetFolderChildren(trashedContainerKey, true).Length); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.MoveToRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.MoveToRecycleBin.cs new file mode 100644 index 000000000000..132ff6810fbe --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.MoveToRecycleBin.cs @@ -0,0 +1,119 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementEditingServiceTests +{ + [Test] + public async Task Can_Move_Element_From_Root_To_Recycle_Bin() + { + var element = await CreateInvariantElement(); + Assert.AreEqual(1, EntityService.GetRootEntities(UmbracoObjectTypes.Element).Count()); + + var moveResult = await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, moveResult.Result); + }); + + await AssertElementIsInRecycleBin(element.Key); + Assert.AreEqual(0, EntityService.GetRootEntities(UmbracoObjectTypes.Element).Count()); + } + + [Test] + public async Task Can_Move_Element_From_A_Folder_To_Recycle_Bin() + { + var containerKey = Guid.NewGuid(); + var container = (await ElementContainerService.CreateAsync(containerKey, "Root Container", null, Constants.Security.SuperUserKey)).Result; + + var element = await CreateInvariantElement(containerKey); + Assert.AreEqual(container.Id, element.ParentId); + Assert.AreEqual(1, GetFolderChildren(containerKey).Length); + + var moveResult = await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(moveResult.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, moveResult.Result); + }); + + await AssertElementIsInRecycleBin(element.Key); + Assert.AreEqual(0, EntityService.GetRootEntities(UmbracoObjectTypes.Element).Count()); + } + + [Test] + public async Task Can_Count_Recycle_Bin_Children_At_Root() + { + var container = (await ElementContainerService.CreateAsync(Guid.NewGuid(), "Root Container", null, Constants.Security.SuperUserKey)).Result; + var element = await CreateInvariantElement(); + + await ElementContainerService.MoveToRecycleBinAsync(container.Key, Constants.Security.SuperUserKey); + await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + var entities = EntityService + .GetPagedChildren( + Constants.System.RecycleBinElementKey, + parentObjectTypes: [UmbracoObjectTypes.ElementContainer], + childObjectTypes: [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element], + skip: 0, + take: 0, + trashed: true, + out var total) + .ToArray(); + + Assert.AreEqual(2, total); + Assert.IsEmpty(entities); + } + + [Test] + public async Task Can_Count_Recycle_Bin_Children_In_Trashed_Folder() + { + var rootContainer = (await ElementContainerService.CreateAsync(Guid.NewGuid(), "Root Container", null, Constants.Security.SuperUserKey)).Result; + await ElementContainerService.CreateAsync(Guid.NewGuid(), "Child Container 1", rootContainer.Key, Constants.Security.SuperUserKey); + await ElementContainerService.CreateAsync(Guid.NewGuid(), "Child Container 2", rootContainer.Key, Constants.Security.SuperUserKey); + await CreateInvariantElement(rootContainer.Key); + + await ElementContainerService.MoveToRecycleBinAsync(rootContainer.Key, Constants.Security.SuperUserKey); + + var entities = EntityService + .GetPagedChildren( + rootContainer.Key, + parentObjectTypes: [UmbracoObjectTypes.ElementContainer], + childObjectTypes: [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element], + skip: 0, + take: 0, + trashed: true, + out var total) + .ToArray(); + + Assert.AreEqual(3, total); + Assert.IsEmpty(entities); + } + + private async Task AssertElementIsInRecycleBin(Guid elementKey) + { + var element = await ElementEditingService.GetAsync(elementKey); + Assert.NotNull(element); + Assert.Multiple(() => + { + Assert.AreEqual(Constants.System.RecycleBinElement, element.ParentId); + Assert.AreEqual($"{Constants.System.RecycleBinElementPathPrefix}{element.Id}", element.Path); + Assert.IsTrue(element.Trashed); + }); + + var recycleBinItems = EntityService + .GetPagedTrashedChildren(Constants.System.RecycleBinElementKey, UmbracoObjectTypes.Element, 0, 10, out var total) + .ToArray(); + Assert.Multiple(() => + { + Assert.AreEqual(1, total); + Assert.AreEqual(1, recycleBinItems.Length); + }); + + Assert.AreEqual(element.Key, recycleBinItems[0].Key); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Update.cs new file mode 100644 index 000000000000..d087c3943131 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Update.cs @@ -0,0 +1,183 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementEditingServiceTests +{ + [Test] + public async Task Can_Update_Invariant() + { + var element = await CreateInvariantElement(); + + var updateModel = new ElementUpdateModel + { + Variants = + [ + new VariantModel { Name = "Updated Name" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The updated title" }, + new PropertyValueModel { Alias = "text", Value = "The updated text" } + ], + }; + + var result = await ElementEditingService.UpdateAsync(element.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyUpdate(result.Result.Content); + + // re-get and re-test + VerifyUpdate(await ElementEditingService.GetAsync(element.Key)); + + void VerifyUpdate(IElement? updatedElement) + { + Assert.IsNotNull(updatedElement); + Assert.AreEqual("Updated Name", updatedElement.Name); + Assert.AreEqual("The updated title", updatedElement.GetValue("title")); + Assert.AreEqual("The updated text", updatedElement.GetValue("text")); + } + } + + [Test] + public async Task Can_Update_Culture_Variant() + { + var element = await CreateCultureVariantElement(); + + var updateModel = new ElementUpdateModel + { + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated English title", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated Danish title", Culture = "da-DK" }, + ], + Variants = + [ + new VariantModel { Culture = "en-US", Name = "Updated English Name" }, + new VariantModel { Culture = "da-DK", Name = "Updated Danish Name" } + ], + }; + + var result = await ElementEditingService.UpdateAsync(element.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyUpdate(result.Result.Content); + + // re-get and re-test + VerifyUpdate(await ElementEditingService.GetAsync(element.Key)); + + void VerifyUpdate(IElement? updatedElement) + { + Assert.IsNotNull(updatedElement); + Assert.AreEqual("Updated English Name", updatedElement.GetCultureName("en-US")); + Assert.AreEqual("Updated Danish Name", updatedElement.GetCultureName("da-DK")); + Assert.AreEqual("The updated invariant title", updatedElement.GetValue("invariantTitle")); + Assert.AreEqual("The updated English title", updatedElement.GetValue("variantTitle", "en-US")); + Assert.AreEqual("The updated Danish title", updatedElement.GetValue("variantTitle", "da-DK")); + } + } + + [Test] + public async Task Can_Update_Segment_Variant() + { + var element = await CreateSegmentVariantElement(); + + var updateModel = new ElementUpdateModel + { + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated default title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-1 title", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-2 title", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Name = "The Updated Name" }, + new VariantModel { Segment = "seg-1", Name = "The Updated Name" }, + new VariantModel { Segment = "seg-2", Name = "The Updated Name" } + ], + }; + + var result = await ElementEditingService.UpdateAsync(element.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyUpdate(result.Result.Content); + + // re-get and re-test + VerifyUpdate(await ElementEditingService.GetAsync(element.Key)); + + void VerifyUpdate(IElement? updatedElement) + { + Assert.IsNotNull(updatedElement); + Assert.Multiple(() => + { + Assert.AreEqual("The Updated Name", updatedElement.Name); + Assert.AreEqual("The updated invariant title", updatedElement.GetValue("invariantTitle")); + Assert.AreEqual("The updated default title", updatedElement.GetValue("variantTitle", segment: null)); + Assert.AreEqual("The updated seg-1 title", updatedElement.GetValue("variantTitle", segment: "seg-1")); + Assert.AreEqual("The updated seg-2 title", updatedElement.GetValue("variantTitle", segment: "seg-2")); + }); + } + } + + [Test] + public async Task Can_Update_Culture_And_Segment_Variant() + { + var element = await CreateCultureAndSegmentVariantElement(); + + var updateModel = new ElementUpdateModel + { + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated default title in English", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-1 title in English", Culture = "en-US", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-2 title in English", Culture = "en-US", Segment = "seg-2" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated default title in Danish", Culture = "da-DK" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-1 title in Danish", Culture = "da-DK", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-2 title in Danish", Culture = "da-DK", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Name = "The Updated English Name", Culture = "en-US" }, + new VariantModel { Name = "The Updated English Name", Culture = "en-US", Segment = "seg-1" }, + new VariantModel { Name = "The Updated English Name", Culture = "en-US", Segment = "seg-2" }, + new VariantModel { Name = "The Updated Danish Name", Culture = "da-DK" }, + new VariantModel { Name = "The Updated Danish Name", Culture = "da-DK", Segment = "seg-1" }, + new VariantModel { Name = "The Updated Danish Name", Culture = "da-DK", Segment = "seg-2" } + ], + }; + + var result = await ElementEditingService.UpdateAsync(element.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyUpdate(result.Result.Content); + + // re-get and re-test + VerifyUpdate(await ElementEditingService.GetAsync(element.Key)); + + void VerifyUpdate(IElement? updatedElement) + { + Assert.IsNotNull(updatedElement); + Assert.Multiple(() => + { + Assert.AreEqual("The Updated English Name", updatedElement.GetCultureName("en-US")); + Assert.AreEqual("The Updated Danish Name", updatedElement.GetCultureName("da-DK")); + + Assert.AreEqual("The updated invariant title", updatedElement.GetValue("invariantTitle")); + Assert.AreEqual("The updated default title in English", updatedElement.GetValue("variantTitle", culture: "en-US", segment: null)); + Assert.AreEqual("The updated seg-1 title in English", updatedElement.GetValue("variantTitle", culture: "en-US", segment: "seg-1")); + Assert.AreEqual("The updated seg-2 title in English", updatedElement.GetValue("variantTitle", culture: "en-US", segment: "seg-2")); + Assert.AreEqual("The updated default title in Danish", updatedElement.GetValue("variantTitle", culture: "da-DK", segment: null)); + Assert.AreEqual("The updated seg-1 title in Danish", updatedElement.GetValue("variantTitle", culture: "da-DK", segment: "seg-1")); + Assert.AreEqual("The updated seg-2 title in Danish", updatedElement.GetValue("variantTitle", culture: "da-DK", segment: "seg-2")); + }); + } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.cs new file mode 100644 index 000000000000..1cf0b50e0299 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.cs @@ -0,0 +1,230 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +/// +/// NOTE: ElementEditingService and ContentEditingService share most of their implementation. +/// +/// as such, these tests for ElementEditingService are not exhaustive, because that would require too much +/// duplication from the ContentEditingService tests, without any real added value. +/// +/// instead, these tests focus on validating that the most basic functionality is in place for element editing. +/// +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + PublishedRepositoryEvents = true, + WithApplication = true)] +public partial class ElementEditingServiceTests : UmbracoIntegrationTest +{ + [SetUp] + public void Setup() => ContentRepositoryBase.ThrowOnWarning = true; + + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder + .AddNotificationHandler(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IElementPublishingService ElementPublishingService => GetRequiredService(); + + private IElementContainerService ElementContainerService => GetRequiredService(); + + private ILanguageService LanguageService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + private async Task CreateInvariantElementType(bool allowedAtRoot = true) + { + var elementType = new ContentTypeBuilder() + .WithAlias("invariantTest") + .WithName("Invariant Test") + .WithAllowAsRoot(allowedAtRoot) + .WithIsElement(true) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .Done() + .AddPropertyType() + .WithAlias("text") + .WithName("Text") + .Done() + .Build(); + + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + return elementType; + } + + private async Task CreateVariantElementType(ContentVariation variation = ContentVariation.Culture, bool variantTitleAsMandatory = true, bool allowedAtRoot = true) + { + var language = new LanguageBuilder() + .WithCultureInfo("da-DK") + .Build(); + await LanguageService.CreateAsync(language, Constants.Security.SuperUserKey); + + var elementType = new ContentTypeBuilder() + .WithAlias("cultureVariationTest") + .WithName("Culture Variation Test") + .WithAllowAsRoot(allowedAtRoot) + .WithIsElement(true) + .WithContentVariation(variation) + .AddPropertyType() + .WithAlias("variantTitle") + .WithName("Variant Title") + .WithMandatory(variantTitleAsMandatory) + .WithVariations(variation) + .Done() + .AddPropertyType() + .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(variation) + .Done() + .Build(); + + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + return elementType; + } + + private async Task CreateInvariantElement(Guid? parentKey = null) + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = parentKey, + Variants = + [ + new VariantModel { Name = "Initial Name" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The initial title" }, + new PropertyValueModel { Alias = "text", Value = "The initial text" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content!; + } + + private async Task CreateCultureVariantElement(Guid? parentKey = null) + { + var elementType = await CreateVariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = parentKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The initial invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial English title", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial Danish title", Culture = "da-DK" } + ], + Variants = + [ + new VariantModel { Culture = "en-US", Name = "Initial English Name" }, + new VariantModel { Culture = "da-DK", Name = "Initial Danish Name" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content!; + } + + private async Task CreateSegmentVariantElement(Guid? parentKey = null) + { + var elementType = await CreateVariantElementType(ContentVariation.Segment); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = parentKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The initial invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial default title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-1 title", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-2 title", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Segment = null, Name = "The Name" }, + new VariantModel { Segment = "seg-1", Name = "The Name" }, + new VariantModel { Segment = "seg-2", Name = "The Name" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content!; + } + + private async Task CreateCultureAndSegmentVariantElement(Guid? parentKey = null) + { + var elementType = await CreateVariantElementType(ContentVariation.CultureAndSegment); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = parentKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The initial invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial title in English", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-1 title in English", Culture = "en-US", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-2 title in English", Culture = "en-US", Segment = "seg-2" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial title in Danish", Culture = "da-DK" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-1 title in Danish", Culture = "da-DK", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-2 title in Danish", Culture = "da-DK", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Name = "The Name", Culture = "en-US", Segment = null }, + new VariantModel { Name = "The Name", Culture = "en-US", Segment = "seg-1" }, + new VariantModel { Name = "The Name", Culture = "en-US", Segment = "seg-2" }, + new VariantModel { Name = "The Name", Culture = "da-DK", Segment = null }, + new VariantModel { Name = "The Name", Culture = "da-DK", Segment = "seg-1" }, + new VariantModel { Name = "The Name", Culture = "da-DK", Segment = "seg-2" }, + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content!; + } + + private IEntitySlim[] GetFolderChildren(Guid containerKey, bool trashed = false) + => EntityService.GetPagedChildren(containerKey, [UmbracoObjectTypes.ElementContainer], [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element], 0, 999, trashed, out _).ToArray(); + + internal sealed class ElementNotificationHandler : INotificationHandler + { + public static Action? UnpublishingElement { get; set; } + + public void Handle(ElementUnpublishingNotification notification) => UnpublishingElement?.Invoke(notification); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementServiceNotificationWithCacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementServiceNotificationWithCacheTests.cs new file mode 100644 index 000000000000..1a3eccec787a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementServiceNotificationWithCacheTests.cs @@ -0,0 +1,655 @@ +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + PublishedRepositoryEvents = true, + WithApplication = true, + Logger = UmbracoTestOptions.Logger.Console)] +internal sealed class ElementServiceNotificationWithCacheTests : UmbracoIntegrationTest +{ + private IContentType _contentType; + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IElementService ElementService => GetRequiredService(); + + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + protected override void ConfigureTestServices(IServiceCollection services) + => services.AddSingleton(AppCaches.Create(Mock.Of())); + + [SetUp] + public async Task SetupTest() + { + ContentRepositoryBase.ThrowOnWarning = true; + + _contentType = ContentTypeBuilder.CreateBasicElementType(); + _contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(_contentType, Constants.Security.SuperUserKey); + } + + [TearDown] + public void Teardown() => ContentRepositoryBase.ThrowOnWarning = false; + + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler(); + + [Test] + public async Task Saving_Saved_Get_Value() + { + var createAttempt = await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Initial name" } + ], + }, + Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsTrue(createAttempt.Success); + Assert.IsNotNull(createAttempt.Result.Content); + }); + + var savingWasCalled = false; + var savedWasCalled = false; + + ElementNotificationHandler.SavingElement = notification => + { + savingWasCalled = true; + + var saved = notification.SavedEntities.First(); + var element = ElementService.GetById(saved.Key)!; + + Assert.Multiple(() => + { + Assert.AreEqual("Updated name", saved.Name); + Assert.AreEqual("Initial name", element.Name); + }); + }; + + ElementNotificationHandler.SavedElement = notification => + { + savedWasCalled = true; + + var saved = notification.SavedEntities.First(); + var element = ElementService.GetById(saved.Key)!; + + Assert.Multiple(() => + { + Assert.AreEqual("Updated name", saved.Name); + Assert.AreEqual("Updated name", element.Name); + }); + }; + + try + { + var updateAttempt = await ElementEditingService.UpdateAsync( + createAttempt.Result.Content!.Key, + new ElementUpdateModel + { + Variants = [ + new() { Name = "Updated name" } + ], + }, + Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsTrue(updateAttempt.Success); + Assert.IsNotNull(updateAttempt.Result.Content); + }); + + Assert.IsTrue(savingWasCalled); + Assert.IsTrue(savedWasCalled); + } + finally + { + ElementNotificationHandler.SavingElement = null; + ElementNotificationHandler.SavedElement = null; + } + } + + [Test] + public async Task Moving_Moved_Fires_Notifications() + { + var container = (await ElementContainerService.CreateAsync(null, "Target", null, Constants.Security.SuperUserKey)).Result; + + var element = (await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Name" } + ], + }, + Constants.Security.SuperUserKey)).Result.Content!; + + var movingWasCalled = false; + var movedWasCalled = false; + + ElementNotificationHandler.MovingElement = _ => movingWasCalled = true; + ElementNotificationHandler.MovedElement = _ => movedWasCalled = true; + + try + { + var moveAttempt = await ElementEditingService.MoveAsync(element.Key, container.Key, Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsTrue(moveAttempt.Success); + Assert.AreEqual(moveAttempt.Result, ContentEditingOperationStatus.Success); + }); + + Assert.IsTrue(movingWasCalled); + Assert.IsTrue(movedWasCalled); + } + finally + { + ElementNotificationHandler.MovingElement = null; + ElementNotificationHandler.MovedElement = null; + } + + element = (await ElementEditingService.GetAsync(element.Key))!; + Assert.AreEqual(container.Id, element.ParentId); + } + + [Test] + public async Task Moving_Can_Cancel_Move() + { + var container = (await ElementContainerService.CreateAsync(null, "Target", null, Constants.Security.SuperUserKey)).Result; + + var element = (await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Name" } + ], + }, + Constants.Security.SuperUserKey)).Result.Content!; + + var movingWasCalled = false; + var movedWasCalled = false; + + ElementNotificationHandler.MovingElement = notification => + { + movingWasCalled = true; + notification.Cancel = true; + }; + ElementNotificationHandler.MovedElement = _ => movedWasCalled = true; + + try + { + var moveAttempt = await ElementEditingService.MoveAsync(element.Key, container.Key, Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsFalse(moveAttempt.Success); + Assert.AreEqual(moveAttempt.Result, ContentEditingOperationStatus.CancelledByNotification); + }); + + Assert.IsTrue(movingWasCalled); + Assert.IsFalse(movedWasCalled); + } + finally + { + ElementNotificationHandler.MovingElement = null; + ElementNotificationHandler.MovedElement = null; + } + + element = (await ElementEditingService.GetAsync(element.Key))!; + Assert.AreEqual(Constants.System.Root, element.ParentId); + } + + [Test] + public async Task Delete_Fires_Notifications() + { + var element = (await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Name" } + ], + }, + Constants.Security.SuperUserKey)).Result.Content!; + + var deletingWasCalled = false; + var deletedWasCalled = false; + + ElementNotificationHandler.DeletingElement = _ => deletingWasCalled = true; + ElementNotificationHandler.DeletedElement = _ => deletedWasCalled = true; + + Assert.IsNotNull(EntityService.Get(element.Key, UmbracoObjectTypes.Element)); + + try + { + var moveAttempt = await ElementEditingService.DeleteAsync(element.Key, Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsTrue(moveAttempt.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, moveAttempt.Status); + }); + + Assert.IsTrue(deletingWasCalled); + Assert.IsTrue(deletedWasCalled); + } + finally + { + ElementNotificationHandler.DeletingElement = null; + ElementNotificationHandler.DeletedElement = null; + } + + Assert.IsNull(EntityService.Get(element.Key, UmbracoObjectTypes.Element)); + } + + [Test] + public async Task Delete_Can_Cancel_Deletion() + { + var element = (await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Name" } + ], + }, + Constants.Security.SuperUserKey)).Result.Content!; + + var deletingWasCalled = false; + var deletedWasCalled = false; + + ElementNotificationHandler.DeletingElement = notification => + { + notification.Cancel = true; + deletingWasCalled = true; + }; + ElementNotificationHandler.DeletedElement = _ => deletedWasCalled = true; + + try + { + var deleteAttempt = await ElementEditingService.DeleteAsync(element.Key, Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsFalse(deleteAttempt.Success); + Assert.AreEqual(ContentEditingOperationStatus.CancelledByNotification, deleteAttempt.Status); + }); + + Assert.IsTrue(deletingWasCalled); + Assert.IsFalse(deletedWasCalled); + } + finally + { + ElementNotificationHandler.DeletingElement = null; + ElementNotificationHandler.DeletedElement = null; + } + + element = (await ElementEditingService.GetAsync(element.Key))!; + Assert.NotNull(element); + } + + [Test] + public async Task Moving_To_Recycle_Bin_Moved_Fires_Notifications() + { + var element = (await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Name" } + ], + }, + Constants.Security.SuperUserKey)).Result.Content!; + + Assert.IsNotNull(element); + var elementKey = element.Key; + var elementPath = element.Path; + + var movingWasCalled = false; + var movedWasCalled = false; + + try + { + ElementNotificationHandler.MovingElementToRecycleBin = notification => + { + movingWasCalled = true; + var moveInfo = notification.MoveInfoCollection.Single(); + Assert.AreEqual(elementKey, moveInfo.Entity.Key); + Assert.AreEqual(elementPath, moveInfo.OriginalPath); + }; + ElementNotificationHandler.MovedElementToRecycleBin = notification => + { + movedWasCalled = true; + var moveInfo = notification.MoveInfoCollection.Single(); + Assert.AreEqual(elementKey, moveInfo.Entity.Key); + Assert.AreEqual(elementPath, moveInfo.OriginalPath); + }; + + var moveAttempt = await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsTrue(moveAttempt.Success); + Assert.AreEqual(moveAttempt.Result, ContentEditingOperationStatus.Success); + }); + + Assert.IsTrue(movingWasCalled); + Assert.IsTrue(movedWasCalled); + } + finally + { + ElementNotificationHandler.MovingElement = null; + ElementNotificationHandler.MovedElement = null; + } + + element = (await ElementEditingService.GetAsync(element.Key))!; + Assert.AreEqual(Constants.System.RecycleBinElement, element.ParentId); + } + + [Test] + public async Task Moving_To_Recycle_Bin_Can_Cancel_Move() + { + var element = (await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Name" } + ], + }, + Constants.Security.SuperUserKey)).Result.Content!; + + var movingWasCalled = false; + var movedWasCalled = false; + + ElementNotificationHandler.MovingElementToRecycleBin = notification => + { + movingWasCalled = true; + notification.Cancel = true; + }; + ElementNotificationHandler.MovedElementToRecycleBin = _ => movedWasCalled = true; + + try + { + var moveAttempt = await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsFalse(moveAttempt.Success); + Assert.AreEqual(moveAttempt.Result, ContentEditingOperationStatus.CancelledByNotification); + }); + + Assert.IsTrue(movingWasCalled); + Assert.IsFalse(movedWasCalled); + } + finally + { + ElementNotificationHandler.MovingElement = null; + ElementNotificationHandler.MovedElement = null; + } + + element = (await ElementEditingService.GetAsync(element.Key))!; + Assert.AreEqual(Constants.System.Root, element.ParentId); + } + + [Test] + public async Task Delete_From_Recycle_Bin_Fires_Notifications() + { + var element = (await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Name" } + ], + }, + Constants.Security.SuperUserKey)).Result.Content!; + + await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + var deletingWasCalled = false; + var deletedWasCalled = false; + + ElementNotificationHandler.DeletingElement = _ => deletingWasCalled = true; + ElementNotificationHandler.DeletedElement = _ => deletedWasCalled = true; + + try + { + var moveAttempt = await ElementEditingService.DeleteFromRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsTrue(moveAttempt.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, moveAttempt.Status); + }); + + Assert.IsTrue(deletingWasCalled); + Assert.IsTrue(deletedWasCalled); + } + finally + { + ElementNotificationHandler.DeletingElement = null; + ElementNotificationHandler.DeletedElement = null; + } + } + + [Test] + public async Task Delete_From_Recycle_Bin_Can_Cancel_Deletion() + { + var element = (await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Name" } + ], + }, + Constants.Security.SuperUserKey)).Result.Content!; + + await ElementEditingService.MoveToRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + var deletingWasCalled = false; + var deletedWasCalled = false; + + ElementNotificationHandler.DeletingElement = notification => + { + notification.Cancel = true; + deletingWasCalled = true; + }; + ElementNotificationHandler.DeletedElement = _ => deletedWasCalled = true; + + try + { + var deleteAttempt = await ElementEditingService.DeleteFromRecycleBinAsync(element.Key, Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsFalse(deleteAttempt.Success); + Assert.AreEqual(ContentEditingOperationStatus.CancelledByNotification, deleteAttempt.Status); + }); + + Assert.IsTrue(deletingWasCalled); + Assert.IsFalse(deletedWasCalled); + } + finally + { + ElementNotificationHandler.DeletingElement = null; + ElementNotificationHandler.DeletedElement = null; + } + + element = (await ElementEditingService.GetAsync(element.Key))!; + Assert.NotNull(element); + } + + [Test] + public async Task Copying_Copied_Fires_Notifications() + { + var element = (await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Name" } + ], + }, + Constants.Security.SuperUserKey)).Result.Content!; + + var copyingWasCalled = false; + var copiedWasCalled = false; + + ElementNotificationHandler.CopyingElement = _ => copyingWasCalled = true; + ElementNotificationHandler.CopiedElement = _ => copiedWasCalled = true; + + try + { + var copyAttempt = await ElementEditingService.CopyAsync(element.Key, null, Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsTrue(copyAttempt.Success); + Assert.AreEqual(copyAttempt.Status, ContentEditingOperationStatus.Success); + }); + + Assert.IsTrue(copyingWasCalled); + Assert.IsTrue(copiedWasCalled); + } + finally + { + ElementNotificationHandler.CopyingElement = null; + ElementNotificationHandler.CopiedElement = null; + } + } + + [Test] + public async Task Copying_Can_Cancel_Copy() + { + var element = (await ElementEditingService.CreateAsync( + new ElementCreateModel + { + ContentTypeKey = _contentType.Key, + Variants = [ + new() { Name = "Name" } + ], + }, + Constants.Security.SuperUserKey)).Result.Content!; + + var copyingWasCalled = false; + var copiedWasCalled = false; + + ElementNotificationHandler.CopyingElement = notification => + { + notification.Cancel = true; + copyingWasCalled = true; + }; + ElementNotificationHandler.CopiedElement = _ => copiedWasCalled = true; + + try + { + var copyAttempt = await ElementEditingService.CopyAsync(element.Key, null, Constants.Security.SuperUserKey); + + Assert.Multiple(() => + { + Assert.IsFalse(copyAttempt.Success); + Assert.AreEqual(copyAttempt.Status, ContentEditingOperationStatus.CancelledByNotification); + }); + + Assert.IsTrue(copyingWasCalled); + Assert.IsFalse(copiedWasCalled); + } + finally + { + ElementNotificationHandler.CopyingElement = null; + ElementNotificationHandler.CopiedElement = null; + } + + Assert.AreEqual(1, EntityService.GetRootEntities(UmbracoObjectTypes.Element).Count()); + } + + internal sealed class ElementNotificationHandler : + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler + { + public static Action? SavingElement { get; set; } + + public static Action? SavedElement { get; set; } + + public static Action? MovingElement { get; set; } + + public static Action? MovedElement { get; set; } + + public static Action? CopyingElement { get; set; } + + public static Action? CopiedElement { get; set; } + + public static Action? MovingElementToRecycleBin { get; set; } + + public static Action? MovedElementToRecycleBin { get; set; } + + public static Action? DeletingElement { get; set; } + + public static Action? DeletedElement { get; set; } + + public void Handle(ElementSavedNotification notification) => SavedElement?.Invoke(notification); + + public void Handle(ElementSavingNotification notification) => SavingElement?.Invoke(notification); + + public void Handle(ElementMovingNotification notification) => MovingElement?.Invoke(notification); + + public void Handle(ElementMovedNotification notification) => MovedElement?.Invoke(notification); + + public void Handle(ElementCopyingNotification notification) => CopyingElement?.Invoke(notification); + + public void Handle(ElementCopiedNotification notification) => CopiedElement?.Invoke(notification); + + public void Handle(ElementMovingToRecycleBinNotification notification) => MovingElementToRecycleBin?.Invoke(notification); + + public void Handle(ElementMovedToRecycleBinNotification notification) => MovedElementToRecycleBin?.Invoke(notification); + + public void Handle(ElementDeletingNotification notification) => DeletingElement?.Invoke(notification); + + public void Handle(ElementDeletedNotification notification) => DeletedElement?.Invoke(notification); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementVersionCleanupServiceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementVersionCleanupServiceTest.cs new file mode 100644 index 000000000000..e06fa03b69fd --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementVersionCleanupServiceTest.cs @@ -0,0 +1,193 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal class ElementVersionCleanupServiceTest : UmbracoIntegrationTest +{ + public IContentTypeService ContentTypeService => GetRequiredService(); + + public IElementService ElementService => GetRequiredService(); + + public IElementVersionService ElementVersionService => GetRequiredService(); + + [Test] + public void PerformElementVersionCleanup_WithNoKeepPeriods_DeletesEverythingExceptActive() + { + var elementType = ContentTypeBuilder.CreateSimpleElementType(); + + // Kill all historic + elementType.HistoryCleanup.PreventCleanup = false; + elementType.HistoryCleanup.KeepAllVersionsNewerThanDays = 0; + elementType.HistoryCleanup.KeepLatestVersionPerDayForDays = 0; + + ContentTypeService.Save(elementType); + + var element = ElementBuilder.CreateSimpleElement(elementType); + ElementService.Save(element); + ElementService.Publish(element, Array.Empty()); + + for (var i = 0; i < 10; i++) + { + ElementService.Publish(element, Array.Empty()); + } + + var before = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(12, before.ContentVersions); // 10 historic + current draft + current published + Assert.AreEqual(12, before.ElementVersions); + Assert.AreEqual(12 * 3, before.PropertyData); // CreateSimpleContentType = 3 props + }); + + ElementVersionService.PerformContentVersionCleanup(DateTime.UtcNow.AddHours(1)); + + var after = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(2, after.ContentVersions); // current draft, current published + Assert.AreEqual(2, after.ElementVersions); + Assert.AreEqual(6, after.PropertyData); // CreateSimpleContentType = 3 props + }); + } + + [Test] + public void PerformElementVersionCleanup_WithPreventCleanup_DeletesNothing() + { + var elementType = ContentTypeBuilder.CreateSimpleElementType(); + + // Retain all historic + elementType.HistoryCleanup.PreventCleanup = true; + + ContentTypeService.Save(elementType); + + var element = ElementBuilder.CreateSimpleElement(elementType); + ElementService.Save(element); + ElementService.Publish(element, Array.Empty()); + + for (var i = 0; i < 10; i++) + { + ElementService.Publish(element, Array.Empty()); + } + + var before = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(12, before.ContentVersions); // 10 historic + current draft + current published + Assert.AreEqual(12, before.ElementVersions); + Assert.AreEqual(12 * 3, before.PropertyData); // CreateSimpleContentType = 3 props + }); + + ElementVersionService.PerformContentVersionCleanup(DateTime.UtcNow.AddHours(1)); + + var after = GetReport(); + + // no changes + Assert.Multiple(() => + { + Assert.AreEqual(before.ContentVersions, after.ContentVersions); + Assert.AreEqual(before.ElementVersions, after.ElementVersions); + Assert.AreEqual(before.PropertyData, after.PropertyData); + }); + } + + [Test] + public async Task PerformElementVersionCleanup_CanPreventCleanupOfSpecificVersions() + { + var elementType = ContentTypeBuilder.CreateSimpleElementType(); + + // Kill all historic + elementType.HistoryCleanup.PreventCleanup = false; + elementType.HistoryCleanup.KeepAllVersionsNewerThanDays = 0; + elementType.HistoryCleanup.KeepLatestVersionPerDayForDays = 0; + + ContentTypeService.Save(elementType); + + var element = ElementBuilder.CreateSimpleElement(elementType); + ElementService.Save(element); + ElementService.Publish(element, Array.Empty()); + + var retainedVersionIds = new List(); + for (var i = 0; i < 10; i++) + { + var result = ElementService.Publish(element, Array.Empty()); + if (i < 5) + { + retainedVersionIds.Add(result.Content.VersionId); + await ElementVersionService.SetPreventCleanupAsync(retainedVersionIds.Last().ToGuid(), true, Constants.Security.SuperUserKey); + } + } + + var before = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(12, before.ContentVersions); // 10 historic + current draft + current published + Assert.AreEqual(12, before.ElementVersions); + Assert.AreEqual(12 * 3, before.PropertyData); // CreateSimpleContentType = 3 props + }); + + ElementVersionService.PerformContentVersionCleanup(DateTime.UtcNow.AddHours(1)); + + var after = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(7, after.ContentVersions); // current draft, current published + 5 retained versions + Assert.AreEqual(7, after.ElementVersions); + Assert.AreEqual(7 * 3, after.PropertyData); // CreateSimpleContentType = 3 props + }); + + var allVersions = await ElementVersionService.GetPagedContentVersionsAsync(element.Key, null, 0, 1000); + Assert.IsTrue(allVersions.Success); + Assert.AreEqual(7, allVersions.Result.Total); + + var allVersionIds = allVersions.Result.Items.Select(item => item.VersionId).ToArray(); + Assert.AreNotEqual(element.VersionId, element.PublishedVersionId); + Assert.Contains(element.VersionId, allVersionIds); + Assert.Contains(element.PublishedVersionId, allVersionIds); + foreach (var retainedVersionId in retainedVersionIds) + { + Assert.Contains(retainedVersionId, allVersionIds); + } + } + + private Report GetReport() + { + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + // SQL CE is fun! + var contentVersions = + ScopeAccessor.AmbientScope.Database.Single(@"select count(1) from umbracoContentVersion"); + var elementVersions = + ScopeAccessor.AmbientScope.Database.Single(@"select count(1) from umbracoElementVersion"); + var propertyData = + ScopeAccessor.AmbientScope.Database.Single(@"select count(1) from umbracoPropertyData"); + + return new Report + { + ContentVersions = contentVersions, + ElementVersions = elementVersions, + PropertyData = propertyData + }; + } + } + + private class Report + { + public int ContentVersions { get; set; } + + public int ElementVersions { get; set; } + + public int PropertyData { get; set; } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTestsIsolated.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTestsIsolated.cs new file mode 100644 index 000000000000..bf5222ba538e --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTestsIsolated.cs @@ -0,0 +1,62 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.ContentTypeEditing; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +/// +/// Tests covering the EntityService (isolated tests, new DB schema per test) +/// +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class EntityServiceTestsIsolated : UmbracoIntegrationTest +{ + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IContentService ContentService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + public IContentTypeEditingService ContentTypeEditingService => GetRequiredService(); + + [TestCase(false)] + [TestCase(true)] + public void EntityService_Can_Count_Trashed_Content_Children_At_Root(bool useTrashed) + { + var contentType = ContentTypeBuilder.CreateSimpleContentType(); + ContentTypeService.Save(contentType); + + var root = ContentBuilder.CreateSimpleContent(contentType); + ContentService.Save(root); + for (var i = 0; i < 10; i++) + { + var content = ContentBuilder.CreateSimpleContent(contentType, Guid.NewGuid().ToString(), root); + ContentService.Save(content); + + if (i % 2 == 0) + { + ContentService.MoveToRecycleBin(content); + } + } + + // get paged entities at recycle bin root + long total; + var entities = useTrashed + ? EntityService + .GetPagedTrashedChildren(Constants.System.RecycleBinContent, UmbracoObjectTypes.Document, 0, 0, out total) + .ToArray() + : EntityService + .GetPagedChildren(Constants.System.RecycleBinContentKey, [UmbracoObjectTypes.Document], [UmbracoObjectTypes.Document], 0, 0, true, out total); + + Assert.AreEqual(5, total); + Assert.IsEmpty(entities); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyValueLevelDetectionTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyValueLevelDetectionTests.cs index 118f3758dc82..3a7ed0a57378 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyValueLevelDetectionTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyValueLevelDetectionTests.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; +using PublishedElement = Umbraco.Cms.Core.PublishedCache.PublishedElement; namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index fb192f687b2e..d8181a962207 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -304,5 +304,74 @@ ContentBlueprintEditingServiceTests.cs + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementPublishingServiceTests.cs + + + ElementPublishingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementContainerServiceTests.cs + + + ElementContainerServiceTests.cs + + + ElementContainerServiceTests.cs + + + ElementContainerServiceTests.cs + + + ElementContainerServiceTests.cs + + + ElementContainerServiceTests.cs + + + ElementContainerServiceTests.cs + + + ElementContainerServiceTests.cs + + + ElementEditingServiceTests.cs + + + UserStartNodeEntitiesServiceElementTests.cs + + + UserStartNodeEntitiesServiceElementTests.cs + + + UserStartNodeEntitiesServiceElementTests.cs + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs index 3f00ac1e5ef8..27ecf666563a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs @@ -91,6 +91,27 @@ public void ContentCacheRefresherCanDeserializeJsonPayloadWithCultures() }); } + [TestCase(TreeChangeTypes.None)] + [TestCase(TreeChangeTypes.RefreshAll)] + [TestCase(TreeChangeTypes.RefreshBranch)] + [TestCase(TreeChangeTypes.Remove)] + [TestCase(TreeChangeTypes.RefreshNode)] + public void ElementCacheRefresherCanDeserializeJsonPayload(TreeChangeTypes changeTypes) + { + var key = Guid.NewGuid(); + ElementCacheRefresher.JsonPayload[] source = + { + new(1234, key, changeTypes) + }; + + var json = JsonSerializer.Serialize(source); + var payload = JsonSerializer.Deserialize(json); + + Assert.AreEqual(1234, payload[0].Id); + Assert.AreEqual(key, payload[0].Key); + Assert.AreEqual(changeTypes, payload[0].ChangeTypes); + } + [Test] public void ContentTypeCacheRefresherCanDeserializeJsonPayload() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs index 0940c1053378..7d19bd9d975e 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs @@ -132,7 +132,7 @@ public void Resolves_Types() public void GetDataEditors() { var types = _typeLoader.GetDataEditors(); - Assert.AreEqual(42, types.Count()); + Assert.AreEqual(43, types.Count()); } /// diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs index d1454bd27da1..25c876389faf 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs @@ -26,7 +26,7 @@ public void PerformContentVersionCleanup_Always_RespectsDeleteRevisionsCancellat DateTime aDateTime, ContentVersionService sut) { - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(someHistoricVersions); eventAggregator.Setup(x => x.PublishCancelable(It.IsAny())) @@ -58,7 +58,7 @@ public void PerformContentVersionCleanup_Always_FiresDeletedVersionsForEachDelet DateTime aDateTime, ContentVersionService sut) { - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(someHistoricVersions); eventAggregator @@ -84,7 +84,7 @@ public void PerformContentVersionCleanup_Always_ReturnsReportOfDeletedItems( DateTime aDateTime, ContentVersionService sut) { - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(someHistoricVersions); eventAggregator @@ -111,7 +111,7 @@ public void PerformContentVersionCleanup_Always_AdheresToCleanupPolicy( DateTime aDateTime, ContentVersionService sut) { - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(someHistoricVersions); eventAggregator @@ -146,7 +146,7 @@ public void PerformContentVersionCleanup_HasVersionsToDelete_CallsDeleteOnReposi DateTime aDateTime, ContentVersionService sut) { - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(someHistoricVersions); eventAggregator diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs index 6233e220413c..1e675f809567 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs @@ -42,7 +42,7 @@ public void Apply_AllOlderThanKeepSettings_AllVersionsReturned( documentVersionRepository.Setup(x => x.GetCleanupPolicies()) .Returns(Array.Empty()); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); @@ -78,7 +78,7 @@ public void Apply_OverlappingKeepSettings_KeepAllVersionsNewerThanDaysTakesPrior documentVersionRepository.Setup(x => x.GetCleanupPolicies()) .Returns(Array.Empty()); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); @@ -122,7 +122,7 @@ public void Apply_WithinInKeepLatestPerDay_ReturnsSinglePerContentPerDay( documentVersionRepository.Setup(x => x.GetCleanupPolicies()) .Returns(Array.Empty()); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); @@ -175,7 +175,7 @@ public void Apply_HasOverridePolicy_RespectsPreventCleanup( new() { ContentTypeId = 2, PreventCleanup = true }, }); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); @@ -218,7 +218,7 @@ public void Apply_HasOverridePolicy_RespectsKeepAll( new() { ContentTypeId = 2, PreventCleanup = false, KeepAllVersionsNewerThanDays = 3 }, }); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); @@ -266,7 +266,7 @@ public void Apply_HasOverridePolicy_RespectsKeepLatest( new() { ContentTypeId = 2, PreventCleanup = false, KeepLatestVersionPerDayForDays = 3 }, }); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/IdKeyMapTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/IdKeyMapTests.cs index 3f267fee1ba1..e12d143025c8 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/IdKeyMapTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/IdKeyMapTests.cs @@ -30,6 +30,15 @@ public void CanResolveMediaRecycleBinIdFromKey() Assert.AreEqual(Constants.System.RecycleBinMedia, result.Result); } + [TestCase(UmbracoObjectTypes.Element)] + [TestCase(UmbracoObjectTypes.ElementContainer)] + public void CanResolveElementRecycleBinIdFromKey(UmbracoObjectTypes objectType) + { + var result = GetSubject().GetIdForKey(Constants.System.RecycleBinElementKey, objectType); + Assert.IsTrue(result.Success); + Assert.AreEqual(Constants.System.RecycleBinElement, result.Result); + } + [Test] public void CanResolveContentRecycleBinKeyFromId() { @@ -45,4 +54,13 @@ public void CanResolveMediaRecycleBinKeyFromId() Assert.IsTrue(result.Success); Assert.AreEqual(Constants.System.RecycleBinMediaKey, result.Result); } + + [TestCase(UmbracoObjectTypes.Element)] + [TestCase(UmbracoObjectTypes.ElementContainer)] + public void CanResolveElementRecycleBinKeyFromId(UmbracoObjectTypes objectType) + { + var result = GetSubject().GetKeyForId(Constants.System.RecycleBinElement, objectType); + Assert.IsTrue(result.Success); + Assert.AreEqual(Constants.System.RecycleBinElementKey, result.Result); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Editors/UserEditorAuthorizationHelperTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Editors/UserEditorAuthorizationHelperTests.cs index 400ec8e6cb30..aab6817f2345 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Editors/UserEditorAuthorizationHelperTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Editors/UserEditorAuthorizationHelperTests.cs @@ -118,7 +118,7 @@ public bool Can_only_add_user_groups_you_are_part_of_yourself_unless_you_are_adm { var currentUser = Mock.Of(user => user.Groups == new[] { - new ReadOnlyUserGroup(1, Guid.NewGuid(), "CurrentUser", null, "icon-user", null, null, groupAlias, new int[0], new string[0], new HashSet(), new HashSet(), true), + new ReadOnlyUserGroup(1, Guid.NewGuid(), "CurrentUser", null, "icon-user", null, null, null, groupAlias, new int[0], new string[0], new HashSet(), new HashSet(), true), }); IUser savingUser = null; // This means it is a new created user diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/Mappers/ElementMapperTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/Mappers/ElementMapperTest.cs new file mode 100644 index 000000000000..7ccdacb14f8a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/Mappers/ElementMapperTest.cs @@ -0,0 +1,45 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Infrastructure.Persistence.Mappers; +using Umbraco.Cms.Tests.UnitTests.TestHelpers; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Persistence.Mappers; + +[TestFixture] +public class ElementMapperTest +{ + [Test] + public void Can_Map_Id_Property() + { + var column = new ElementMapper(TestHelper.GetMockSqlContext(), TestHelper.CreateMaps()).Map(nameof(Element.Id)); + Assert.That(column, Is.EqualTo($"[{Constants.DatabaseSchema.Tables.Node}].[id]")); + } + + [Test] + public void Can_Map_Trashed_Property() + { + var column = + new ElementMapper(TestHelper.GetMockSqlContext(), TestHelper.CreateMaps()).Map(nameof(Element.Trashed)); + Assert.That(column, Is.EqualTo($"[{Constants.DatabaseSchema.Tables.Node}].[trashed]")); + } + + [Test] + public void Can_Map_Published_Property() + { + var column = + new ElementMapper(TestHelper.GetMockSqlContext(), TestHelper.CreateMaps()).Map(nameof(Element.Published)); + Assert.That(column, Is.EqualTo($"[{Constants.DatabaseSchema.Tables.Element}].[published]")); + } + + [Test] + public void Can_Map_Version_Property() + { + var column = + new ElementMapper(TestHelper.GetMockSqlContext(), TestHelper.CreateMaps()).Map(nameof(Element.VersionId)); + Assert.That(column, Is.EqualTo($"[{Constants.DatabaseSchema.Tables.ContentVersion}].[id]")); + } +}