From 36dc35f8aad80ac314ac5e0634807cf819b9ae91 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 10 Feb 2023 08:32:24 +0100 Subject: [PATCH] Add "move" to dictionary API (#13810) * Sanitize dictionary overview and export actions * Amend dictionary services with async and attempt pattern + isolate temporary file handling in its own service. * Update OpenAPI schema to match new dictionary bulk actions * Implement move API for dictionary items. * Add unit tests for dictionary item move * Fix merge * Update OpenAPI json after merge --- .../Dictionary/DictionaryControllerBase.cs | 4 + .../Dictionary/MoveDictionaryController.cs | 45 ++ src/Umbraco.Cms.Api.Management/OpenApi.json | 536 ++++++++++-------- .../Dictionary/DictionaryMoveModel.cs | 6 + .../DictionaryItemMovedNotification.cs | 17 + .../DictionaryItemMovingNotification.cs | 17 + .../Services/DictionaryItemService.cs | 61 ++ .../Services/IDictionaryItemService.cs | 8 + .../DictionaryItemOperationStatus.cs | 3 +- .../Services/DictionaryItemServiceTests.cs | 137 +++++ 10 files changed, 594 insertions(+), 240 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Dictionary/MoveDictionaryController.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryMoveModel.cs create mode 100644 src/Umbraco.Core/Notifications/DictionaryItemMovedNotification.cs create mode 100644 src/Umbraco.Core/Notifications/DictionaryItemMovingNotification.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/DictionaryControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/DictionaryControllerBase.cs index eb66fa3153..02e49021d2 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/DictionaryControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/DictionaryControllerBase.cs @@ -26,6 +26,10 @@ public abstract class DictionaryControllerBase : ManagementApiControllerBase .WithTitle("Cancelled by notification") .WithDetail("A notification handler prevented the dictionary item operation.") .Build()), + DictionaryItemOperationStatus.InvalidParent => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid parent") + .WithDetail("The targeted parent dictionary item is not valid for this dictionary item operation.") + .Build()), _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown dictionary operation status") }; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/MoveDictionaryController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/MoveDictionaryController.cs new file mode 100644 index 0000000000..50aff112e5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/MoveDictionaryController.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Dictionary; +using Umbraco.Cms.Core; +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.Dictionary; + +public class MoveDictionaryController : DictionaryControllerBase +{ + private readonly IDictionaryItemService _dictionaryItemService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public MoveDictionaryController(IDictionaryItemService dictionaryItemService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _dictionaryItemService = dictionaryItemService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPost("{key:guid}/move")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Move(Guid key, DictionaryMoveModel dictionaryMoveModel) + { + IDictionaryItem? source = await _dictionaryItemService.GetAsync(key); + if (source == null) + { + return NotFound(); + } + + Attempt result = await _dictionaryItemService.MoveAsync( + source, + dictionaryMoveModel.TargetKey, + CurrentUserId(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : DictionaryItemOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index cfddc40444..a316e1c1c4 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -638,8 +638,8 @@ "201": { "description": "Created" }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { @@ -648,8 +648,8 @@ } } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "content": { "application/json": { "schema": { @@ -863,6 +863,63 @@ } } }, + "/umbraco/management/api/v1/dictionary/{key}/move": { + "post": { + "tags": [ + "Dictionary" + ], + "operationId": "PostDictionaryByKeyMove", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DictionaryMoveModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + } + } + } + }, "/umbraco/management/api/v1/dictionary/import": { "post": { "tags": [ @@ -916,9 +973,7 @@ ], "operationId": "PostDictionaryUpload", "requestBody": { - "content": { - - } + "content": { } }, "responses": { "200": { @@ -1338,16 +1393,6 @@ } ], "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PagedRecycleBinItemModel" - } - } - } - }, "401": { "description": "Unauthorized", "content": { @@ -1357,6 +1402,16 @@ } } } + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedRecycleBinItemModel" + } + } + } } } } @@ -1388,16 +1443,6 @@ } ], "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PagedRecycleBinItemModel" - } - } - } - }, "401": { "description": "Unauthorized", "content": { @@ -1407,6 +1452,16 @@ } } } + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedRecycleBinItemModel" + } + } + } } } } @@ -1641,6 +1696,16 @@ } ], "responses": { + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, "200": { "description": "Success", "content": { @@ -1654,16 +1719,6 @@ } } } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } } } } @@ -1685,6 +1740,16 @@ } ], "responses": { + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, "200": { "description": "Success", "content": { @@ -1698,16 +1763,6 @@ } } } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } } } } @@ -1732,6 +1787,16 @@ } }, "responses": { + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, "200": { "description": "Success", "content": { @@ -1745,16 +1810,6 @@ } } } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } } } } @@ -1806,16 +1861,6 @@ } ], "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PagedHelpPageModel" - } - } - } - }, "400": { "description": "Bad Request", "content": { @@ -1825,6 +1870,16 @@ } } } + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedHelpPageModel" + } + } + } } } } @@ -1884,6 +1939,16 @@ } ], "responses": { + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, "200": { "description": "Success", "content": { @@ -1897,16 +1962,6 @@ } } } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } } } } @@ -1928,16 +1983,6 @@ } ], "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OkResultModel" - } - } - } - }, "400": { "description": "Bad Request", "content": { @@ -1947,6 +1992,16 @@ } } } + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResultModel" + } + } + } } } } @@ -1958,20 +2013,6 @@ ], "operationId": "GetInstallSettings", "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/InstallSettingsModel" - } - ] - } - } - } - }, "400": { "description": "Bad Request", "content": { @@ -1991,6 +2032,20 @@ } } } + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/InstallSettingsModel" + } + ] + } + } + } } } } @@ -2015,9 +2070,6 @@ } }, "responses": { - "200": { - "description": "Success" - }, "400": { "description": "Bad Request", "content": { @@ -2037,6 +2089,9 @@ } } } + }, + "200": { + "description": "Success" } } } @@ -2061,9 +2116,6 @@ } }, "responses": { - "200": { - "description": "Success" - }, "400": { "description": "Bad Request", "content": { @@ -2073,6 +2125,9 @@ } } } + }, + "200": { + "description": "Success" } } } @@ -2135,8 +2190,15 @@ } }, "responses": { - "201": { - "description": "Created" + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } }, "400": { "description": "Bad Request", @@ -2148,15 +2210,8 @@ } } }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } + "201": { + "description": "Created" } } } @@ -2178,6 +2233,16 @@ } ], "responses": { + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, "200": { "description": "Success", "content": { @@ -2191,16 +2256,6 @@ } } } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } } } }, @@ -2220,9 +2275,6 @@ } ], "responses": { - "200": { - "description": "Success" - }, "400": { "description": "Bad Request", "content": { @@ -2242,6 +2294,9 @@ } } } + }, + "200": { + "description": "Success" } } }, @@ -2274,8 +2329,15 @@ } }, "responses": { - "200": { - "description": "Success" + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundResultModel" + } + } + } }, "400": { "description": "Bad Request", @@ -2287,15 +2349,8 @@ } } }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundResultModel" - } - } - } + "200": { + "description": "Success" } } } @@ -2365,9 +2420,6 @@ } ], "responses": { - "200": { - "description": "Success" - }, "400": { "description": "Bad Request", "content": { @@ -2377,6 +2429,9 @@ } } } + }, + "200": { + "description": "Success" } } } @@ -2504,16 +2559,6 @@ } ], "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PagedLogTemplateModel" - } - } - } - }, "400": { "description": "Bad Request", "content": { @@ -2523,6 +2568,16 @@ } } } + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedLogTemplateModel" + } + } + } } } } @@ -2585,9 +2640,6 @@ } }, "responses": { - "201": { - "description": "Created" - }, "400": { "description": "Bad Request", "content": { @@ -2597,6 +2649,9 @@ } } } + }, + "201": { + "description": "Created" } } } @@ -2618,6 +2673,16 @@ } ], "responses": { + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundResultModel" + } + } + } + }, "200": { "description": "Success", "content": { @@ -2631,16 +2696,6 @@ } } } - }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundResultModel" - } - } - } } } }, @@ -2660,9 +2715,6 @@ } ], "responses": { - "200": { - "description": "Success" - }, "404": { "description": "Not Found", "content": { @@ -2672,6 +2724,9 @@ } } } + }, + "200": { + "description": "Success" } } } @@ -2701,9 +2756,6 @@ } ], "responses": { - "200": { - "description": "Success" - }, "400": { "description": "Bad Request", "content": { @@ -2713,6 +2765,9 @@ } } } + }, + "200": { + "description": "Success" } } } @@ -2899,16 +2954,6 @@ } ], "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PagedRecycleBinItemModel" - } - } - } - }, "401": { "description": "Unauthorized", "content": { @@ -2918,6 +2963,16 @@ } } } + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedRecycleBinItemModel" + } + } + } } } } @@ -2949,16 +3004,6 @@ } ], "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PagedRecycleBinItemModel" - } - } - } - }, "401": { "description": "Unauthorized", "content": { @@ -2968,6 +3013,16 @@ } } } + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedRecycleBinItemModel" + } + } + } } } } @@ -3636,16 +3691,6 @@ } ], "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PagedRedirectUrlModel" - } - } - } - }, "400": { "description": "Bad Request", "content": { @@ -3655,6 +3700,16 @@ } } } + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedRedirectUrlModel" + } + } + } } } } @@ -4213,6 +4268,16 @@ ], "operationId": "GetServerStatus", "responses": { + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, "200": { "description": "Success", "content": { @@ -4226,16 +4291,6 @@ } } } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } } } } @@ -4247,6 +4302,16 @@ ], "operationId": "GetServerVersion", "responses": { + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, "200": { "description": "Success", "content": { @@ -4260,16 +4325,6 @@ } } } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } } } } @@ -4606,9 +4661,6 @@ } }, "responses": { - "200": { - "description": "Success" - }, "400": { "description": "Bad Request", "content": { @@ -4618,6 +4670,9 @@ } } } + }, + "200": { + "description": "Success" } } } @@ -6031,6 +6086,17 @@ }, "additionalProperties": false }, + "DictionaryMoveModel": { + "type": "object", + "properties": { + "targetKey": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "additionalProperties": false + }, "DictionaryOverviewModel": { "type": "object", "properties": { @@ -6783,9 +6849,7 @@ }, "providerProperties": { "type": "object", - "additionalProperties": { - - }, + "additionalProperties": { }, "nullable": true } }, @@ -8437,9 +8501,7 @@ "nullable": true } }, - "additionalProperties": { - - } + "additionalProperties": { } }, "ProfilingStatusModel": { "type": "object", @@ -9919,9 +9981,7 @@ "authorizationCode": { "authorizationUrl": "/umbraco/management/api/v1.0/security/back-office/authorize", "tokenUrl": "/umbraco/management/api/v1.0/security/back-office/token", - "scopes": { - - } + "scopes": { } } } } @@ -9929,9 +9989,7 @@ }, "security": [ { - "OAuth": [ - - ] + "OAuth": [ ] } ] } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryMoveModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryMoveModel.cs new file mode 100644 index 0000000000..5ab8850a3e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryMoveModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Dictionary; + +public class DictionaryMoveModel +{ + public Guid? TargetKey { get; set; } +} diff --git a/src/Umbraco.Core/Notifications/DictionaryItemMovedNotification.cs b/src/Umbraco.Core/Notifications/DictionaryItemMovedNotification.cs new file mode 100644 index 0000000000..f58e39bb4c --- /dev/null +++ b/src/Umbraco.Core/Notifications/DictionaryItemMovedNotification.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public class DictionaryItemMovedNotification : MovedNotification +{ + public DictionaryItemMovedNotification(MoveEventInfo target, EventMessages messages) + : base(target, messages) + { + } + + public DictionaryItemMovedNotification(IEnumerable> target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/DictionaryItemMovingNotification.cs b/src/Umbraco.Core/Notifications/DictionaryItemMovingNotification.cs new file mode 100644 index 0000000000..35586104aa --- /dev/null +++ b/src/Umbraco.Core/Notifications/DictionaryItemMovingNotification.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public class DictionaryItemMovingNotification : MovingNotification +{ + public DictionaryItemMovingNotification(MoveEventInfo target, EventMessages messages) + : base(target, messages) + { + } + + public DictionaryItemMovingNotification(IEnumerable> target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Services/DictionaryItemService.cs b/src/Umbraco.Core/Services/DictionaryItemService.cs index 09711ec6c5..1b6c0de6ae 100644 --- a/src/Umbraco.Core/Services/DictionaryItemService.cs +++ b/src/Umbraco.Core/Services/DictionaryItemService.cs @@ -171,6 +171,67 @@ internal sealed class DictionaryItemService : RepositoryService, IDictionaryItem } } + /// + public async Task> MoveAsync( + IDictionaryItem dictionaryItem, + Guid? parentId, + int userId = Constants.Security.SuperUserId) + { + // same parent? then just ignore this operation, assume success. + if (dictionaryItem.ParentId == parentId) + { + return Attempt.SucceedWithStatus(DictionaryItemOperationStatus.Success, dictionaryItem); + } + + // cannot move a dictionary item underneath itself + if (dictionaryItem.Key == parentId) + { + return Attempt.FailWithStatus(DictionaryItemOperationStatus.InvalidParent, dictionaryItem); + } + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + IDictionaryItem? parent = parentId.HasValue ? _dictionaryRepository.Get(parentId.Value) : null; + + // validate parent if applicable + if (parentId.HasValue && parent == null) + { + return Attempt.FailWithStatus(DictionaryItemOperationStatus.ParentNotFound, dictionaryItem); + } + + // ensure we don't move a dictionary item underneath one of its own descendants + if (parent != null) + { + IEnumerable descendants = _dictionaryRepository.GetDictionaryItemDescendants(dictionaryItem.Key); + if (descendants.Any(item => item.Key == parent.Key)) + { + return Attempt.FailWithStatus(DictionaryItemOperationStatus.InvalidParent, dictionaryItem); + } + } + + dictionaryItem.ParentId = parentId; + + EventMessages eventMessages = EventMessagesFactory.Get(); + var moveEventInfo = new MoveEventInfo(dictionaryItem, string.Empty, parent?.Id ?? Constants.System.Root); + var movingNotification = new DictionaryItemMovingNotification(moveEventInfo, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(movingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(DictionaryItemOperationStatus.CancelledByNotification, dictionaryItem); + } + + _dictionaryRepository.Save(dictionaryItem); + + scope.Notifications.Publish( + new DictionaryItemMovedNotification(moveEventInfo, eventMessages).WithStateFrom(movingNotification)); + + Audit(AuditType.Move, "Move DictionaryItem", userId, dictionaryItem.Id, nameof(DictionaryItem)); + scope.Complete(); + + return await Task.FromResult(Attempt.SucceedWithStatus(DictionaryItemOperationStatus.Success, dictionaryItem)); + } + } + private async Task> SaveAsync( IDictionaryItem dictionaryItem, Func operationValidation, diff --git a/src/Umbraco.Core/Services/IDictionaryItemService.cs b/src/Umbraco.Core/Services/IDictionaryItemService.cs index 9d6fce5220..7aa5f2364a 100644 --- a/src/Umbraco.Core/Services/IDictionaryItemService.cs +++ b/src/Umbraco.Core/Services/IDictionaryItemService.cs @@ -90,4 +90,12 @@ public interface IDictionaryItemService /// The ID of the to delete /// Optional id of the user deleting the dictionary item Task> DeleteAsync(Guid id, int userId = Constants.Security.SuperUserId); + + /// + /// Moves a object + /// + /// to move + /// Id of the new parent, null if the item should be moved to the root + /// Optional id of the user moving the dictionary item + Task> MoveAsync(IDictionaryItem dictionaryItem, Guid? parentId, int userId = Constants.Security.SuperUserId); } diff --git a/src/Umbraco.Core/Services/OperationStatus/DictionaryItemOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/DictionaryItemOperationStatus.cs index b993118ddb..4081a22185 100644 --- a/src/Umbraco.Core/Services/OperationStatus/DictionaryItemOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/DictionaryItemOperationStatus.cs @@ -8,5 +8,6 @@ public enum DictionaryItemOperationStatus ItemNotFound, ParentNotFound, InvalidId, - DuplicateKey + DuplicateKey, + InvalidParent } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DictionaryItemServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DictionaryItemServiceTests.cs index c59260f205..dfa87ba61c 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DictionaryItemServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DictionaryItemServiceTests.cs @@ -174,6 +174,39 @@ public class DictionaryItemServiceTests : UmbracoIntegrationTest Assert.AreEqual(1, item.Translations.Count()); } + [Test] + public async Task Can_Create_DictionaryItem_Under_Parent_DictionaryItem() + { + var english = await LanguageService.GetAsync("en-US"); + + var result = await DictionaryItemService.CreateAsync( + new DictionaryItem("Testing123") + { + Translations = new List { new DictionaryTranslation(english, "Hello parent") } + }); + Assert.True(result.Success); + var parentKey = result.Result.Key; + + result = await DictionaryItemService.CreateAsync( + new DictionaryItem("Testing456") + { + Translations = new List { new DictionaryTranslation(english, "Hello child") }, + ParentId = parentKey + }); + Assert.True(result.Success); + + // re-get + var item = await DictionaryItemService.GetAsync(result.Result!.Key); + Assert.NotNull(item); + + Assert.Greater(item.Id, 0); + Assert.IsTrue(item.HasIdentity); + Assert.IsTrue(item.ParentId.HasValue); + Assert.AreEqual("Testing456", item.ItemKey); + Assert.AreEqual(1, item.Translations.Count()); + Assert.AreEqual(parentKey, item.ParentId); + } + [Test] public async Task Can_Create_DictionaryItem_At_Root_With_All_Languages() { @@ -331,6 +364,68 @@ public class DictionaryItemServiceTests : UmbracoIntegrationTest } } + [Test] + public async Task Can_Move_DictionaryItem_To_Root() + { + var rootOneKey = (await DictionaryItemService.CreateAsync(new DictionaryItem("RootOne"))).Result.Key; + var childKey = (await DictionaryItemService.CreateAsync(new DictionaryItem("ChildOne") { ParentId = rootOneKey })).Result.Key; + + var child = await DictionaryItemService.GetAsync(childKey); + Assert.AreEqual(rootOneKey, child.ParentId); + + var result = await DictionaryItemService.MoveAsync(child, null); + Assert.IsTrue(result.Success); + Assert.AreEqual(DictionaryItemOperationStatus.Success, result.Status); + + child = await DictionaryItemService.GetAsync(childKey); + Assert.AreEqual(null, child.ParentId); + + var rootItemKeys = (await DictionaryItemService.GetAtRootAsync()).Select(item => item.Key); + Assert.True(rootItemKeys.Contains(childKey)); + + var rootOneChildren = await DictionaryItemService.GetChildrenAsync(rootOneKey); + Assert.AreEqual(0, rootOneChildren.Count()); + } + + [Test] + public async Task Can_Move_DictionaryItem_To_Other_Parent() + { + var rootOneKey = (await DictionaryItemService.CreateAsync(new DictionaryItem("RootOne"))).Result.Key; + var rootTwoKey = (await DictionaryItemService.CreateAsync(new DictionaryItem("RootTwo"))).Result.Key; + var childKey = (await DictionaryItemService.CreateAsync(new DictionaryItem("ChildOne") { ParentId = rootOneKey })).Result.Key; + + var child = await DictionaryItemService.GetAsync(childKey); + Assert.AreEqual(rootOneKey, child.ParentId); + + var result = await DictionaryItemService.MoveAsync(child, rootTwoKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(DictionaryItemOperationStatus.Success, result.Status); + + child = await DictionaryItemService.GetAsync(childKey); + Assert.AreEqual(rootTwoKey, child.ParentId); + } + + [Test] + public async Task Can_Move_DictionaryItem_From_Root() + { + var rootOneKey = (await DictionaryItemService.CreateAsync(new DictionaryItem("RootOne"))).Result.Key; + var rootTwoKey = (await DictionaryItemService.CreateAsync(new DictionaryItem("RootTwo"))).Result.Key; + var childKey = (await DictionaryItemService.CreateAsync(new DictionaryItem("ChildOne") { ParentId = rootOneKey })).Result.Key; + + var rootTwo = await DictionaryItemService.GetAsync(rootTwoKey); + Assert.IsNull(rootTwo.ParentId); + + var result = await DictionaryItemService.MoveAsync(rootTwo, childKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(DictionaryItemOperationStatus.Success, result.Status); + + rootTwo = await DictionaryItemService.GetAsync(rootTwoKey); + Assert.AreEqual(childKey, rootTwo.ParentId); + + var rootItemKeys = (await DictionaryItemService.GetAtRootAsync()).Select(item => item.Key); + Assert.IsFalse(rootItemKeys.Contains(rootTwoKey)); + } + [Test] public async Task Cannot_Add_Duplicate_DictionaryItem_ItemKey() { @@ -450,6 +545,48 @@ public class DictionaryItemServiceTests : UmbracoIntegrationTest Assert.AreEqual(DictionaryItemOperationStatus.InvalidId, result.Status); } + [Test] + public async Task Cannot_Move_DictionaryItem_To_Itself() + { + var root = (await DictionaryItemService.CreateAsync(new DictionaryItem("RootOne"))).Result; + + var result = await DictionaryItemService.MoveAsync(root, root.Key); + Assert.IsFalse(result.Success); + Assert.AreEqual(DictionaryItemOperationStatus.InvalidParent, result.Status); + + root = await DictionaryItemService.GetAsync(root.Key); + Assert.IsNull(root.ParentId); + } + + [Test] + public async Task Cannot_Move_DictionaryItem_To_Child() + { + var root = (await DictionaryItemService.CreateAsync(new DictionaryItem("RootOne"))).Result; + var child = (await DictionaryItemService.CreateAsync(new DictionaryItem("ChildOne") { ParentId = root.Key })).Result; + + var result = await DictionaryItemService.MoveAsync(root, child.Key); + Assert.IsFalse(result.Success); + Assert.AreEqual(DictionaryItemOperationStatus.InvalidParent, result.Status); + + root = await DictionaryItemService.GetAsync(root.Key); + Assert.IsNull(root.ParentId); + } + + [Test] + public async Task Cannot_Move_DictionaryItem_To_Descendant() + { + var root = (await DictionaryItemService.CreateAsync(new DictionaryItem("RootOne"))).Result; + var child = (await DictionaryItemService.CreateAsync(new DictionaryItem("ChildOne") { ParentId = root.Key })).Result; + var grandChild = (await DictionaryItemService.CreateAsync(new DictionaryItem("GrandChildOne") { ParentId = child.Key })).Result; + + var result = await DictionaryItemService.MoveAsync(root, grandChild.Key); + Assert.IsFalse(result.Success); + Assert.AreEqual(DictionaryItemOperationStatus.InvalidParent, result.Status); + + root = await DictionaryItemService.GetAsync(root.Key); + Assert.IsNull(root.ParentId); + } + private async Task CreateTestData() { var languageDaDk = new LanguageBuilder()