From 5384ca8035da629d872449b9690c998bec61be21 Mon Sep 17 00:00:00 2001 From: kjac Date: Tue, 12 May 2026 10:27:24 +0200 Subject: [PATCH 01/13] Reintroduce create/save-and-publish as a single operation --- .../CreateAndPublishDocumentController.cs | 70 +++ .../UpdateAndPublishDocumentController.cs | 70 +++ src/Umbraco.Cms.Api.Management/OpenApi.json | 406 +++++++++++++++++- .../CreateAndPublishDocumentRequestModel.cs | 12 + .../UpdateAndPublishDocumentRequestModel.cs | 12 + .../Services/ContentEditingService.cs | 57 ++- src/Umbraco.Core/Services/ContentService.cs | 122 ++++++ .../Services/IContentEditingService.cs | 23 + src/Umbraco.Core/Services/IContentService.cs | 16 + 9 files changed, 785 insertions(+), 3 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/CreateAndPublishDocumentRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateAndPublishDocumentRequestModel.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs new file mode 100644 index 000000000000..abe6caa3f52d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs @@ -0,0 +1,70 @@ +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.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.Document; + +/// +/// API controller responsible for handling operations related to the creation of content documents in the Umbraco CMS. +/// +[ApiVersion("1.0")] +public class CreateAndPublishDocumentController : CreateDocumentControllerBase +{ + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + private readonly IContentEditingService _contentEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// Service used to authorize access to document creation operations. + /// Factory for creating document editing presentation models. + /// Service responsible for content editing functionality. + /// Accessor for back office security context. + public CreateAndPublishDocumentController( + IAuthorizationService authorizationService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory, + IContentEditingService contentEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService) + { + _documentEditingPresentationFactory = documentEditingPresentationFactory; + _contentEditingService = contentEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + /// + /// Creates a new document using the specified request model, and subsequently publishes the document in the cultures provided. + /// + /// Token to monitor for cancellation requests. + /// The details of the document to create. + /// An representing the result of the operation. + [HttpPost("create-and-publish")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [EndpointSummary("Creates and publishes a new document.")] + [EndpointDescription("Creates and publishes a new document with the configuration specified in the request model.")] + public async Task Create( + CancellationToken cancellationToken, + CreateAndPublishDocumentRequestModel requestModel) + => await HandleRequest(requestModel, async () => + { + ContentCreateModel model = _documentEditingPresentationFactory.MapCreateModel(requestModel); + Attempt result = + await _contentEditingService.CreateAndPublishAsync(model, requestModel.CulturesToPublish, 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/Document/UpdateAndPublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs new file mode 100644 index 000000000000..eed5cc048dff --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs @@ -0,0 +1,70 @@ +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.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.Document; + +/// +/// Controller responsible for handling update-and-publish operations on documents in the management API. +/// +[ApiVersion("1.0")] +public class UpdateAndPublishDocumentController : UpdateDocumentControllerBase +{ + private readonly IContentEditingService _contentEditingService; + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// Service for verifying user permissions. + /// Service for managing content updates. + /// Factory for creating document editing presentation models. + /// Accessor for the back office user security context. + public UpdateAndPublishDocumentController( + IAuthorizationService authorizationService, + IContentEditingService contentEditingService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService) + { + _contentEditingService = contentEditingService; + _documentEditingPresentationFactory = documentEditingPresentationFactory; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + /// Updates the specified document with new details provided in the request model, and subsequently publishes the document in the cultures provided. + /// A token to monitor for cancellation requests. + /// The unique identifier of the document to update. + /// The model containing the updated document details. + /// An representing the outcome of the update operation. + [HttpPut("{id:guid}/update-and-publish")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [EndpointSummary("Updates and publishes a document.")] + [EndpointDescription("Updates and publishes a document identified by the provided Id with the details from the request model.")] + public async Task Update( + CancellationToken cancellationToken, + Guid id, + UpdateAndPublishDocumentRequestModel requestModel) + => await HandleRequest(id, requestModel, async () => + { + ContentUpdateModel model = _documentEditingPresentationFactory.MapUpdateModel(requestModel); + Guid currentUserKey = CurrentUserKey(_backOfficeSecurityAccessor); + Attempt result = await _contentEditingService.UpdateAndPublishAsync(id, model, requestModel.CulturesToPublish, currentUserKey); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 54397e3677ce..0f3b89bbf030 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -11032,6 +11032,150 @@ ] } }, + "/umbraco/management/api/v1/document/{id}/update-and-publish": { + "put": { + "tags": [ + "Document" + ], + "summary": "Updates and publishes a document.", + "description": "Updates and publishes a document identified by the provided Id with the details from the request model.", + "operationId": "PutDocumentByIdUpdateAndPublish", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateAndPublishDocumentRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateAndPublishDocumentRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdateAndPublishDocumentRequestModel" + } + ] + } + } + } + }, + "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.1/document/{id}/validate": { "put": { "tags": [ @@ -11282,6 +11426,154 @@ ] } }, + "/umbraco/management/api/v1/document/create-and-publish": { + "post": { + "tags": [ + "Document" + ], + "summary": "Creates and publishes a new document.", + "description": "Creates and publishes a new document with the configuration specified in the request model.", + "operationId": "PostDocumentCreateAndPublish", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateAndPublishDocumentRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateAndPublishDocumentRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateAndPublishDocumentRequestModel" + } + ] + } + } + } + }, + "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/document/sort": { "put": { "tags": [ @@ -39935,6 +40227,73 @@ }, "additionalProperties": false }, + "CreateAndPublishDocumentRequestModel": { + "required": [ + "culturesToPublish", + "documentType", + "template", + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantRequestModel" + } + ] + } + }, + "id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "parent": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + }, + "template": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "culturesToPublish": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "CreateDataTypeRequestModel": { "required": [ "editorAlias", @@ -51176,6 +51535,51 @@ }, "additionalProperties": false }, + "UpdateAndPublishDocumentRequestModel": { + "required": [ + "culturesToPublish", + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantRequestModel" + } + ] + } + }, + "template": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "culturesToPublish": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "UpdateCurrentUserRequestModel": { "required": [ "languageIsoCode" @@ -53674,4 +54078,4 @@ "name": "Webhook" } ] -} \ No newline at end of file +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/CreateAndPublishDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/CreateAndPublishDocumentRequestModel.cs new file mode 100644 index 000000000000..244d623d0f5d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/CreateAndPublishDocumentRequestModel.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +/// +/// Represents the API request model used for creating and publishing a new content document in Umbraco. +/// +public class CreateAndPublishDocumentRequestModel : CreateDocumentRequestModel +{ + /// + /// The cultures to publish after creating the document. + /// + public string[] CulturesToPublish { get; set; } = []; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateAndPublishDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateAndPublishDocumentRequestModel.cs new file mode 100644 index 000000000000..7459e22dfaa5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/UpdateAndPublishDocumentRequestModel.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +/// +/// Represents a request model used for updating and publishing a document via the API. +/// +public class UpdateAndPublishDocumentRequestModel : UpdateDocumentRequestModel +{ + /// + /// The cultures to publish after updating the document. + /// + public string[] CulturesToPublish { get; set; } = []; +} diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index ae6238b568d8..fe9b4ab86de6 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -135,6 +135,13 @@ public async Task public async Task> CreateAsync(ContentCreateModel createModel, Guid userKey) + => await HandleCreateAsync(createModel, null, userKey); + + /// + public async Task> CreateAndPublishAsync(ContentCreateModel createModel, string[] culturesToPublish, Guid userKey) + => await HandleCreateAsync(createModel, culturesToPublish, userKey); + + private async Task> HandleCreateAsync(ContentCreateModel createModel, string[]? culturesToPublish, Guid userKey) { if (await ValidateCulturesAsync(createModel) is false) { @@ -159,7 +166,9 @@ public async Task> C return Attempt.FailWithStatus(updateTemplateStatus, new ContentCreateResult { Content = content }); } - ContentEditingOperationStatus saveStatus = await Save(content, userKey); + ContentEditingOperationStatus saveStatus = culturesToPublish is null + ? await Save(content, userKey) + : await SaveAndPublish(content, culturesToPublish, userKey); return saveStatus == ContentEditingOperationStatus.Success ? Attempt.SucceedWithStatus(validationStatus, new ContentCreateResult { Content = content, ValidationResult = validationResult }) : Attempt.FailWithStatus(saveStatus, new ContentCreateResult { Content = content }); @@ -264,6 +273,13 @@ private async Task> GetAllowedCulturesForEditingUser(Guid userKe /// public async Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey) + => await HandleUpdateAsync(key, updateModel, null, userKey); + + /// + public async Task> UpdateAndPublishAsync(Guid key, ContentUpdateModel updateModel, string[] culturesToPublish, Guid userKey) + => await HandleUpdateAsync(key, updateModel, culturesToPublish, userKey); + + private async Task> HandleUpdateAsync(Guid key, ContentUpdateModel updateModel, string[]? culturesToPublish, Guid userKey) { IContent? content = ContentService.GetById(key); if (content is null) @@ -295,7 +311,9 @@ public async Task> U return Attempt.FailWithStatus(updateTemplateStatus, new ContentUpdateResult { Content = content }); } - ContentEditingOperationStatus saveStatus = await Save(content, userKey); + ContentEditingOperationStatus saveStatus = culturesToPublish is null + ? await Save(content, userKey) + : await SaveAndPublish(content, culturesToPublish, userKey); return saveStatus == ContentEditingOperationStatus.Success ? Attempt.SucceedWithStatus(validationStatus, new ContentUpdateResult { Content = content, ValidationResult = validationResult }) : Attempt.FailWithStatus(saveStatus, new ContentUpdateResult { Content = content }); @@ -399,6 +417,17 @@ private async Task Save(IContent content, Guid us try { var currentUserId = await GetUserIdAsync(userKey); + // PublishResult publishResult = ((ContentService)ContentService).SaveAndPublish(content); + // if (publishResult.Success) + // { + // return ContentEditingOperationStatus.Success; + // } + // + // return publishResult.Result switch + // { + // PublishResultType.FailedPublishCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification, + // _ => ContentEditingOperationStatus.Unknown, + // }; OperationResult saveResult = ContentService.Save(content, currentUserId); return saveResult.Result switch { @@ -416,4 +445,28 @@ private async Task Save(IContent content, Guid us return ContentEditingOperationStatus.Unknown; } } + + private async Task SaveAndPublish(IContent content, string[] culturesToPublish, Guid userKey) + { + try + { + var currentUserId = await GetUserIdAsync(userKey); + PublishResult publishResult = ContentService.SaveAndPublish(content, culturesToPublish, userId: currentUserId); + if (publishResult.Success) + { + return ContentEditingOperationStatus.Success; + } + + return publishResult.Result switch + { + PublishResultType.FailedPublishCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification, + _ => ContentEditingOperationStatus.Unknown, + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Content save operation failed"); + return ContentEditingOperationStatus.Unknown; + } + } } diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 0bdf80702818..3a79081e6aeb 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1437,6 +1437,128 @@ public PublishResult Publish(IContent content, string[] cultures, int userId = C } } + /// + public PublishResult SaveAndPublish(IContent content, string[] culturesToPublish, int userId = Constants.Security.SuperUserId) + { + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (culturesToPublish == null) + { + throw new ArgumentNullException(nameof(culturesToPublish)); + } + + if (content.Name != null && content.Name.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } + + var varies = content.ContentType.VariesByCulture(); + + if (culturesToPublish.Length == 0 && !varies) + { + // No cultures specified and doesn't vary, so publish it, else nothing to publish + return SaveAndPublish(content, userId: userId); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ContentTree); + + var allLangs = _languageRepository.GetMany().ToList(); + + EventMessages evtMsgs = EventMessagesFactory.Get(); + + var savingNotification = new ContentSavingNotification(content, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); + } + + if (culturesToPublish.Any(x => x == null || x == "*")) + { + throw new InvalidOperationException( + "Only valid cultures are allowed to be used in this method, wildcards or nulls are not allowed"); + } + + IEnumerable impacts = + culturesToPublish.Select(x => _cultureImpactFactory.ImpactExplicit(x, IsDefaultCulture(allLangs, x))); + + // 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. + foreach (CultureImpact impact in impacts) + { + content.PublishCulture(impact, DateTime.UtcNow, _propertyEditorCollection); + } + + PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + scope.Complete(); + return result; + } + + private PublishResult SaveAndPublish(IContent content, string culture = "*", int userId = Constants.Security.SuperUserId) + { + EventMessages evtMsgs = EventMessagesFactory.Get(); + + 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.IsNullOrWhiteSpace()) + { + throw new NotSupportedException("Invariant culture is not supported by variant content types."); + } + } + else + { + if (!culture.IsNullOrWhiteSpace() && culture != "*") + { + throw new NotSupportedException( + $"Culture \"{culture}\" is not supported by invariant content types."); + } + } + + if (content.Name != null && content.Name.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.ContentTree); + + var allLangs = _languageRepository.GetMany().ToList(); + + // Change state to publishing + content.PublishedState = PublishedState.Publishing; + var savingNotification = new ContentSavingNotification(content, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); + } + + // if culture is specific, first publish the invariant values, then publish the culture itself. + // if culture is '*', then publish them all (including variants) + + // this will create the correct culture impact even if culture is * or null + var impact = _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. + content.PublishCulture(impact, DateTime.UtcNow, _propertyEditorCollection); + + PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + scope.Complete(); + return result; + } + /// public PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId) { diff --git a/src/Umbraco.Core/Services/IContentEditingService.cs b/src/Umbraco.Core/Services/IContentEditingService.cs index ee01d66113e6..bfdede3963d5 100644 --- a/src/Umbraco.Core/Services/IContentEditingService.cs +++ b/src/Umbraco.Core/Services/IContentEditingService.cs @@ -41,6 +41,17 @@ public interface IContentEditingService /// An attempt containing the creation result or an error status. Task> CreateAsync(ContentCreateModel createModel, Guid userKey); + /// + /// Creates and publishes a new content item. + /// + /// The model containing the content data. + /// The cultures to publish. + /// The unique identifier of the user performing the action. + /// An attempt containing the creation result or an error status. + // TODO (V18): Remove default implementation. + Task> CreateAndPublishAsync(ContentCreateModel createModel, string[] culturesToPublish, Guid userKey) + => Task.FromResult(Attempt.FailWithStatus(ContentEditingOperationStatus.Unknown, new ContentCreateResult())); + /// /// Updates an existing content item. /// @@ -50,6 +61,18 @@ public interface IContentEditingService /// An attempt containing the update result or an error status. Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey); + /// + /// Updates and publishes an existing content item. + /// + /// The unique identifier of the content item to update. + /// The model containing the updated content data. + /// The cultures to publish. + /// The unique identifier of the user performing the action. + /// An attempt containing the update result or an error status. + // TODO (V18): Remove default implementation. + Task> UpdateAndPublishAsync(Guid key, ContentUpdateModel updateModel, string[] culturesToPublish, Guid userKey) + => Task.FromResult(Attempt.FailWithStatus(ContentEditingOperationStatus.Unknown, new ContentUpdateResult())); + /// /// Moves a content item to the recycle bin. /// diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index 6a4861f39912..2d1c990fd641 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -559,6 +559,22 @@ IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out /// The identifier of the user performing the action. PublishResult Publish(IContent content, string[] cultures, int userId = Constants.Security.SuperUserId); + /// + /// Saves and publishes a document. + /// + /// + /// + /// By default, publishes all variations of the document, but it is possible to specify a culture to be + /// published. + /// + /// When a culture is being published, it includes all varying values along with all invariant values. + /// The document is *always* saved, even when publishing fails. + /// + /// The document to publish. + /// The cultures to publish. + /// The identifier of the user performing the action. + PublishResult SaveAndPublish(IContent content, string[] culturesToPublish, int userId = Constants.Security.SuperUserId); + /// /// Publishes a document branch. /// From d088da831b9c76dd9668060dd175d19f6e8fefdf Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 20 May 2026 22:08:22 +0200 Subject: [PATCH 02/13] Added a truckload on tests. --- .../Services/ContentServiceTests.cs | 390 +++++++++++++++++- ...entEditingServiceTests.CreateAndPublish.cs | 328 +++++++++++++++ ...entEditingServiceTests.UpdateAndPublish.cs | 309 ++++++++++++++ .../Umbraco.Tests.Integration.csproj | 6 + 4 files changed, 1029 insertions(+), 4 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.CreateAndPublish.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.UpdateAndPublish.cs diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs index 491086dca8c5..22b879be487b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs @@ -50,6 +50,7 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent private IDataTypeService DataTypeService => GetRequiredService(); + private ILocalizedTextService LocalizedTextService => GetRequiredService(); private ILanguageService LanguageService => GetRequiredService(); @@ -78,6 +79,8 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent private IValueEditorCache ValueEditorCache => GetRequiredService(); + private ITemplateService TemplateService => GetRequiredService(); + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder .AddNotificationHandler() .AddNotificationHandler() @@ -1730,6 +1733,384 @@ public void Can_Save_And_Publish_Content_And_Child_Without_Identity() Assert.That(childSaved.Success, Is.True); } + #region SaveAndPublish (combined operation) + + [Test] + public void Can_SaveAndPublish_Invariant_Content() + { + var content = ContentService.Create("Home US", -1, "umbTextpage"); + content.SetValue("author", "Barack Obama"); + + var result = ContentService.SaveAndPublish(content, Array.Empty()); + + Assert.IsTrue(result.Success); + Assert.That(content.HasIdentity, Is.True); + Assert.That(content.Published, Is.True); + } + + [Test] + public void Can_SaveAndPublish_Invariant_Content_Without_Prior_Save() + { + var content = ContentService.Create("Unsaved Content", -1, "umbTextpage"); + content.SetValue("author", "Test Author"); + Assert.IsFalse(content.HasIdentity); + + var result = ContentService.SaveAndPublish(content, Array.Empty()); + + Assert.IsTrue(result.Success); + Assert.That(content.HasIdentity, Is.True); + Assert.That(content.Published, Is.True); + + // re-get to verify persistence + var retrieved = ContentService.GetById(content.Id); + Assert.IsNotNull(retrieved); + Assert.AreEqual("Test Author", retrieved.GetValue("author")); + Assert.IsTrue(retrieved.Published); + } + + [TestCase(Constants.Security.SuperUserId)] + [TestCase(-1)] + public void Can_SaveAndPublish_With_Different_User_Ids(int userId) + { + var content = ContentService.GetById(Textpage.Id); + Assert.IsNotNull(content); + + var result = ContentService.SaveAndPublish(content, [], userId); + + Assert.IsTrue(result.Success); + Assert.That(content.Published, Is.True); + } + + [Test] + public void Can_SaveAndPublish_Variant_Content_Multiple_Cultures() + { + var content = CreateEnglishAndFrenchDocument(out var langUk, out var langFr, out _); + + var result = ContentService.SaveAndPublish(content, [langFr.IsoCode, langUk.IsoCode]); + + Assert.IsTrue(result.Success); + Assert.IsTrue(content.IsCulturePublished(langFr.IsoCode)); + Assert.IsTrue(content.IsCulturePublished(langUk.IsoCode)); + + // re-get and verify + content = ContentService.GetById(content.Id)!; + Assert.IsTrue(content.IsCulturePublished(langFr.IsoCode)); + Assert.IsTrue(content.IsCulturePublished(langUk.IsoCode)); + } + + [Test] + public void Can_SaveAndPublish_Variant_Content_Single_Culture() + { + var content = CreateEnglishAndFrenchDocument(out var langUk, out var langFr, out _); + + var result = ContentService.SaveAndPublish(content, [langFr.IsoCode]); + + Assert.IsTrue(result.Success); + Assert.IsTrue(content.IsCulturePublished(langFr.IsoCode)); + Assert.IsFalse(content.IsCulturePublished(langUk.IsoCode)); + + // re-get and verify + content = ContentService.GetById(content.Id)!; + Assert.IsTrue(content.IsCulturePublished(langFr.IsoCode)); + Assert.IsFalse(content.IsCulturePublished(langUk.IsoCode)); + } + + [Test] + public void Can_SaveAndPublish_And_Child_Without_Identity() + { + var content = ContentService.Create("Home US", Constants.System.Root, "umbTextpage"); + content.SetValue("author", "John Doe"); + + var published = ContentService.SaveAndPublish(content, []); + var childContent = ContentService.Create("Child", content.Id, "umbTextpage"); + + // Reset all identity properties + childContent.Id = 0; + childContent.Path = string.Empty; + ((Content)childContent).ResetIdentity(); + var childPublished = ContentService.SaveAndPublish(childContent, []); + + Assert.That(content.HasIdentity, Is.True); + Assert.That(content.Published, Is.True); + Assert.That(childContent.HasIdentity, Is.True); + Assert.That(childContent.Published, Is.True); + Assert.That(published.Success, Is.True); + Assert.That(childPublished.Success, Is.True); + } + + [Test] + public void SaveAndPublish_Fires_Notifications() + { + var savingWasCalled = false; + var publishingWasCalled = false; + var contentName = "contentName"; + + ContentNotificationHandler.SavingContent = notification => + { + savingWasCalled = true; + Assert.AreEqual(1, notification.SavedEntities.Count()); + var entity = notification.SavedEntities.First(); + Assert.AreEqual(contentName, entity.Name); + }; + + ContentNotificationHandler.PublishingContent = notification => + { + publishingWasCalled = true; + Assert.AreEqual(1, notification.PublishedEntities.Count()); + var entity = notification.PublishedEntities.First(); + Assert.AreEqual(contentName, entity.Name); + }; + + try + { + var content = ContentService.GetById(Textpage.Id); + Assert.IsNotNull(content); + content.Name = contentName; + + var result = ContentService.SaveAndPublish(content, []); + + Assert.IsTrue(result.Success); + Assert.IsTrue(content.Published); + Assert.IsTrue(savingWasCalled); + Assert.IsTrue(publishingWasCalled); + } + finally + { + ContentNotificationHandler.SavingContent = null; + ContentNotificationHandler.PublishingContent = null; + } + } + + [Test] + public void SaveAndPublish_Can_Be_Cancelled_By_Saving_Notification() + { + ContentNotificationHandler.SavingContent = notification => + { + notification.Cancel = true; + }; + + try + { + var content = ContentService.Create("Cancel Me", -1, "umbTextpage"); + + var result = ContentService.SaveAndPublish(content, Array.Empty()); + + Assert.IsFalse(result.Success); + Assert.AreEqual(PublishResultType.FailedPublishCancelledByEvent, result.Result); + Assert.IsFalse(content.Published); + } + finally + { + ContentNotificationHandler.SavingContent = null; + } + } + + [Test] + public void SaveAndPublish_Rejects_Invalid_Cultures() + { + var content = CreateEnglishAndFrenchDocument(out _, out _, out _); + + Assert.Throws(() => ContentService.SaveAndPublish(content, ["*"])); + Assert.Throws(() => ContentService.SaveAndPublish(content, [null!])); + Assert.Throws(() => ContentService.SaveAndPublish(content, ["*", null!])); + Assert.Throws(() => ContentService.SaveAndPublish(content, ["en-US", "*", "es-ES"])); + } + + [Test] + public void SaveAndPublish_No_Cultures_On_Variant_Saves_But_Does_Not_Publish() + { + var content = CreateEnglishAndFrenchDocument(out var langUk, out var langFr, out _); + + // First publish both cultures + var published = ContentService.SaveAndPublish(content, [langFr.IsoCode, langUk.IsoCode]); + Assert.IsTrue(published.Success); + + // re-get + content = ContentService.GetById(content.Id)!; + + // Change some data + content.SetCultureName("content-en-updated", langUk.IsoCode); + + // SaveAndPublish with empty cultures - should save data but not publish + var result = ContentService.SaveAndPublish(content, Array.Empty()); + + // re-get and verify data was saved even though nothing was published + content = ContentService.GetById(content.Id)!; + Assert.AreEqual("content-en-updated", content.GetCultureName(langUk.IsoCode)); + } + + [Test] + public void Cannot_SaveAndPublish_Trashed_Content() + { + var content = ContentService.GetById(Trashed.Id); + Assert.IsNotNull(content); + + var result = ContentService.SaveAndPublish(content, Array.Empty()); + + Assert.IsFalse(result.Success); + Assert.IsFalse(content.Published); + Assert.IsTrue(content.Trashed); + } + + [Test] + public void Cannot_SaveAndPublish_Expired_Content() + { + var content = ContentService.GetById(Subpage.Id); + Assert.IsNotNull(content); + var contentSchedule = ContentScheduleCollection.CreateWithEntry(null, DateTime.UtcNow.AddMinutes(-5)); + ContentService.Save(content, contentSchedule: contentSchedule); + + var parent = ContentService.GetById(Textpage.Id); + Assert.IsNotNull(parent); + ContentService.SaveAndPublish(parent, Array.Empty()); + + var result = ContentService.SaveAndPublish(content, Array.Empty()); + + Assert.IsFalse(result.Success); + Assert.IsFalse(content.Published); + Assert.AreEqual(PublishResultType.FailedPublishHasExpired, result.Result); + } + + [Test] + public async Task Cannot_SaveAndPublish_Expired_Culture() + { + var contentType = ContentTypeBuilder.CreateBasicContentType(); + contentType.Variations = ContentVariation.Culture; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var content = ContentBuilder.CreateBasicContent(contentType); + content.SetCultureName("Hello", "en-US"); + var contentSchedule = ContentScheduleCollection.CreateWithEntry("en-US", null, DateTime.UtcNow.AddMinutes(-5)); + ContentService.Save(content, contentSchedule: contentSchedule); + + var result = ContentService.SaveAndPublish(content, ["en-US"]); + + Assert.IsFalse(result.Success); + Assert.AreEqual(PublishResultType.FailedPublishCultureHasExpired, result.Result); + Assert.IsFalse(content.Published); + } + + [Test] + public void Cannot_SaveAndPublish_Content_Awaiting_Release() + { + var content = ContentService.GetById(Subpage.Id); + Assert.IsNotNull(content); + var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddHours(2), null); + ContentService.Save(content, Constants.Security.SuperUserId, contentSchedule); + + var parent = ContentService.GetById(Textpage.Id); + Assert.IsNotNull(parent); + ContentService.SaveAndPublish(parent, Array.Empty()); + + var result = ContentService.SaveAndPublish(content, Array.Empty()); + + Assert.IsFalse(result.Success); + Assert.IsFalse(content.Published); + Assert.AreEqual(PublishResultType.FailedPublishAwaitingRelease, result.Result); + } + + [Test] + public async Task Cannot_SaveAndPublish_Culture_Awaiting_Release() + { + var contentType = ContentTypeBuilder.CreateBasicContentType(); + contentType.Variations = ContentVariation.Culture; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var content = ContentBuilder.CreateBasicContent(contentType); + content.SetCultureName("Hello", "en-US"); + var contentSchedule = ContentScheduleCollection.CreateWithEntry("en-US", DateTime.UtcNow.AddHours(2), null); + ContentService.Save(content, contentSchedule: contentSchedule); + + var result = ContentService.SaveAndPublish(content, ["en-US"]); + + Assert.IsFalse(result.Success); + Assert.AreEqual(PublishResultType.FailedPublishCultureAwaitingRelease, result.Result); + Assert.IsFalse(content.Published); + } + + [Test] + public async Task SaveAndPublish_Invalid_Content_Still_Saves() + { + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + var contentType = ContentTypeBuilder.CreateSimpleContentType( + "umbMandatory", + "Mandatory Doc Type", + mandatoryProperties: true, + defaultTemplateId: template.Id); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var parentId = Textpage.Id; + var parent = ContentService.GetById(parentId); + Assert.IsNotNull(parent); + ContentService.SaveAndPublish(parent, Array.Empty()); + + var content = ContentBuilder.CreateSimpleContent(contentType, "Invalid Content", parentId); + content.SetValue("author", string.Empty); + Assert.IsFalse(content.HasIdentity); + + var result = ContentService.SaveAndPublish(content, Array.Empty()); + + Assert.IsFalse(result.Success); + Assert.AreEqual(PublishResultType.FailedPublishContentInvalid, result.Result); + Assert.IsFalse(content.Published); + + // content IS saved even though publish failed + Assert.Greater(content.Id, 0); + Assert.IsTrue(content.HasIdentity); + } + + [Test] + [LongRunning] + public async Task Failed_SaveAndPublish_Preserves_Edited_State() + { + var contentService = GetRequiredService(); + var contentTypeService = GetRequiredService(); + + var contentType = new ContentTypeBuilder() + .WithId(0) + .AddPropertyType() + .WithAlias("header") + .WithValueStorageType(ValueStorageType.Integer) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithName("header") + .Done() + .WithContentVariation(ContentVariation.Nothing) + .Build(); + + await contentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var content = new ContentBuilder() + .WithId(0) + .WithName("Home") + .WithContentType(contentType) + .AddPropertyData() + .WithKeyValue("header", "Cool header") + .Done() + .Build(); + + contentService.SaveAndPublish(content, Array.Empty()); + + content.Properties[0]!.SetValue("forcedPropertyValue", string.Empty); + contentService.Save(content); + contentService.PersistContentSchedule( + content, + ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddHours(2), null)); + + var result = contentService.SaveAndPublish(content, Array.Empty()); + + Assert.Multiple(() => + { + Assert.IsFalse(result.Success); + Assert.IsTrue(result.Content.Published); + Assert.AreEqual(PublishResultType.FailedPublishAwaitingRelease, result.Result); + Assert.IsTrue(result.Content.Edited, "result.Content.Edited"); + }); + } + + #endregion + [Test] [LongRunning] public void Can_Get_Published_Descendant_Versions() @@ -4193,17 +4574,18 @@ public class ContentNotificationHandler : INotificationHandler, INotificationHandler { - public static Action PublishingContent { get; set; } + public static Action? PublishingContent { get; set; } - public static Action CopyingContent { get; set; } + public static Action? CopyingContent { get; set; } - public static Action CopiedContent { get; set; } + public static Action? CopiedContent { get; set; } - public static Action SavingContent { get; set; } + public static Action? SavingContent { get; set; } public void Handle(ContentCopiedNotification notification) => CopiedContent?.Invoke(notification); public void Handle(ContentCopyingNotification notification) => CopyingContent?.Invoke(notification); + public void Handle(ContentPublishingNotification notification) => PublishingContent?.Invoke(notification); public void Handle(ContentSavingNotification notification) => SavingContent?.Invoke(notification); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.CreateAndPublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.CreateAndPublish.cs new file mode 100644 index 000000000000..1277c5763bd9 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.CreateAndPublish.cs @@ -0,0 +1,328 @@ +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 ContentEditingServiceTests +{ + [Test] + public async Task Can_CreateAndPublish_Invariant_Content() + { + var contentType = CreateInvariantContentType(); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create And Publish" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title" }, + new PropertyValueModel { Alias = "text", Value = "The text" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + VerifyCreateAndPublish(result.Result.Content); + + // re-get and re-test + VerifyCreateAndPublish(await ContentEditingService.GetAsync(result.Result.Content!.Key)); + + void VerifyCreateAndPublish(IContent? content) + { + Assert.IsNotNull(content); + Assert.IsTrue(content.HasIdentity); + Assert.IsTrue(content.Published); + Assert.AreEqual("Test Create And Publish", content.Name); + Assert.AreEqual("The title", content.GetValue("title", published: true)); + Assert.AreEqual("The text", content.GetValue("text", published: true)); + } + } + + [Test] + public async Task Can_CreateAndPublish_Culture_Variant_All_Cultures() + { + var contentType = await CreateVariantContentType(); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.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 = "English Name" }, + new VariantModel { Culture = "da-DK", Name = "Danish Name" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, ["en-US", "da-DK"], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + VerifyCreateAndPublish(result.Result.Content); + + // re-get and re-test + VerifyCreateAndPublish(await ContentEditingService.GetAsync(result.Result.Content!.Key)); + + void VerifyCreateAndPublish(IContent? content) + { + Assert.IsNotNull(content); + Assert.IsTrue(content.Published); + Assert.IsTrue(content.IsCulturePublished("en-US")); + Assert.IsTrue(content.IsCulturePublished("da-DK")); + Assert.AreEqual("English Name", content.GetCultureName("en-US")); + Assert.AreEqual("Danish Name", content.GetCultureName("da-DK")); + Assert.AreEqual("The Invariant Title", content.GetValue("invariantTitle")); + Assert.AreEqual("The English Title", content.GetValue("variantTitle", "en-US", published: true)); + Assert.AreEqual("The Danish Title", content.GetValue("variantTitle", "da-DK", published: true)); + } + } + + [Test] + public async Task Can_CreateAndPublish_Culture_Variant_Single_Culture() + { + var contentType = await CreateVariantContentType(); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.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 = "English Name" }, + new VariantModel { Culture = "da-DK", Name = "Danish Name" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, ["en-US"], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + VerifyCreateAndPublish(result.Result.Content); + + // re-get and re-test + VerifyCreateAndPublish(await ContentEditingService.GetAsync(result.Result.Content!.Key)); + + void VerifyCreateAndPublish(IContent? content) + { + Assert.IsNotNull(content); + Assert.IsTrue(content.IsCulturePublished("en-US")); + Assert.IsFalse(content.IsCulturePublished("da-DK")); + + // both values should still be saved + Assert.AreEqual("The English Title", content.GetValue("variantTitle", "en-US", published: true)); + Assert.AreEqual("The Danish Title", content.GetValue("variantTitle", "da-DK")); + } + } + + [Test] + public async Task Can_CreateAndPublish_With_Template() + { + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + var contentType = ContentTypeBuilder.CreateTextPageContentType(defaultTemplateId: template.Id); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + TemplateKey = template.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "With Template" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title" }, + new PropertyValueModel { Alias = "bodyText", Value = "The body" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + var content = result.Result.Content!; + Assert.IsTrue(content.Published); + Assert.AreEqual(template.Id, content.TemplateId); + } + + [Test] + public async Task Can_CreateAndPublish_With_Explicit_Key() + { + var contentType = CreateInvariantContentType(); + var explicitKey = Guid.NewGuid(); + + var createModel = new ContentCreateModel + { + Key = explicitKey, + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Explicit Key" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + var content = result.Result.Content!; + Assert.IsTrue(content.Published); + Assert.AreEqual(explicitKey, content.Key); + } + + [Test] + public async Task Cannot_CreateAndPublish_Without_Content_Type() + { + var createModel = new ContentCreateModel + { + ContentTypeKey = Guid.NewGuid(), + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ContentTypeNotFound, result.Status); + } + + [Test] + public async Task Cannot_CreateAndPublish_With_Non_Existing_Parent() + { + var contentType = CreateInvariantContentType(); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Guid.NewGuid(), + Variants = + [ + new VariantModel { Name = "Test" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ParentNotFound, result.Status); + } + + [Test] + public async Task Cannot_CreateAndPublish_With_Non_Existing_Template() + { + var contentType = CreateInvariantContentType(); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + TemplateKey = Guid.NewGuid(), + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.TemplateNotFound, result.Status); + } + + [Test] + public async Task Cannot_CreateAndPublish_With_Disallowed_Template() + { + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + // content type without allowed templates + var contentType = ContentTypeBuilder.CreateBasicContentType(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + TemplateKey = template.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.TemplateNotAllowed, result.Status); + } + + [Test] + public async Task Cannot_CreateAndPublish_Invariant_Without_Name() + { + var contentType = CreateInvariantContentType(); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = [], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch, result.Status); + } + + [Test] + public async Task Cannot_CreateAndPublish_With_Invalid_Culture() + { + var contentType = await CreateVariantContentType(); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "Invariant" }, + new PropertyValueModel { Alias = "variantTitle", Value = "English", Culture = "en-us" } + ], + Variants = + [ + new VariantModel { Culture = "en-us", Name = "English" } + ], + }; + + var result = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.InvalidCulture, result.Status); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.UpdateAndPublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.UpdateAndPublish.cs new file mode 100644 index 000000000000..e9f45980b46e --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.UpdateAndPublish.cs @@ -0,0 +1,309 @@ +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 ContentEditingServiceTests +{ + [Test] + public async Task Can_UpdateAndPublish_Invariant() + { + var content = await CreateInvariantContent(); + Assert.IsFalse(content.Published); + + var updateModel = new ContentUpdateModel + { + 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 ContentEditingService.UpdateAndPublishAsync(content.Key, updateModel, [], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + VerifyUpdateAndPublish(result.Result.Content); + + // re-get and re-test + VerifyUpdateAndPublish(await ContentEditingService.GetAsync(content.Key)); + + void VerifyUpdateAndPublish(IContent? updatedContent) + { + Assert.IsNotNull(updatedContent); + Assert.IsTrue(updatedContent.Published); + Assert.AreEqual("Updated Name", updatedContent.Name); + Assert.AreEqual("The updated title", updatedContent.GetValue("title", published: true)); + Assert.AreEqual("The updated text", updatedContent.GetValue("text", published: true)); + } + } + + [Test] + public async Task Can_UpdateAndPublish_Culture_Variant() + { + var content = await CreateCultureVariantContent(); + Assert.IsFalse(content.Published); + + var updateModel = new ContentUpdateModel + { + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "Updated invariant" }, + new PropertyValueModel { Alias = "variantTitle", Value = "Updated English", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "Updated Danish", 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 ContentEditingService.UpdateAndPublishAsync(content.Key, updateModel, ["en-US", "da-DK"], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + VerifyUpdateAndPublish(result.Result.Content); + + // re-get and re-test + VerifyUpdateAndPublish(await ContentEditingService.GetAsync(content.Key)); + + void VerifyUpdateAndPublish(IContent? updatedContent) + { + Assert.IsNotNull(updatedContent); + Assert.IsTrue(updatedContent.Published); + Assert.IsTrue(updatedContent.IsCulturePublished("en-US")); + Assert.IsTrue(updatedContent.IsCulturePublished("da-DK")); + Assert.AreEqual("Updated English Name", updatedContent.GetPublishName("en-US")); + Assert.AreEqual("Updated Danish Name", updatedContent.GetPublishName("da-DK")); + Assert.AreEqual("Updated invariant", updatedContent.GetValue("invariantTitle", published: true)); + Assert.AreEqual("Updated English", updatedContent.GetValue("variantTitle", "en-US", published: true)); + Assert.AreEqual("Updated Danish", updatedContent.GetValue("variantTitle", "da-DK", published: true)); + } + } + + [Test] + public async Task Can_UpdateAndPublish_Single_Culture() + { + var content = await CreateCultureVariantContent(); + + var updateModel = new ContentUpdateModel + { + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "Updated invariant" }, + new PropertyValueModel { Alias = "variantTitle", Value = "Updated English", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "Updated Danish", 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 ContentEditingService.UpdateAndPublishAsync(content.Key, updateModel, ["en-US"], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + VerifyUpdateAndPublish(result.Result.Content); + + // re-get and re-test + VerifyUpdateAndPublish(await ContentEditingService.GetAsync(content.Key)); + + void VerifyUpdateAndPublish(IContent? updatedContent) + { + Assert.IsNotNull(updatedContent); + Assert.IsTrue(updatedContent.IsCulturePublished("en-US")); + Assert.IsFalse(updatedContent.IsCulturePublished("da-DK")); + + // both values should still be saved + Assert.AreEqual("Updated English", updatedContent.GetValue("variantTitle", "en-US", published: true)); + Assert.AreNotEqual("Updated Danish", updatedContent.GetValue("variantTitle", "da-DK", published: true)); + Assert.AreEqual("Updated Danish", updatedContent.GetValue("variantTitle", "da-DK", published: false)); + } + } + + [Test] + public async Task Can_UpdateAndPublish_Already_Published_Content() + { + var contentType = CreateInvariantContentType(); + + // create and publish initially + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Original Name" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "Original title" }, + new PropertyValueModel { Alias = "text", Value = "Original text" } + ], + }; + + var createResult = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + var content = createResult.Result.Content!; + Assert.IsTrue(content.Published); + + // now update and republish + var updateModel = new ContentUpdateModel + { + Variants = + [ + new VariantModel { Name = "Republished Name" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "Republished title" }, + new PropertyValueModel { Alias = "text", Value = "Republished text" } + ], + }; + + var result = await ContentEditingService.UpdateAndPublishAsync(content.Key, updateModel, [], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + var updatedContent = await ContentEditingService.GetAsync(content.Key); + Assert.IsNotNull(updatedContent); + Assert.IsTrue(updatedContent.Published); + Assert.AreEqual("Republished Name", updatedContent.Name); + Assert.AreEqual("Republished title", updatedContent.GetValue("title", published: true)); + Assert.AreEqual("Republished text", updatedContent.GetValue("text", published: true)); + } + + [Test] + public async Task Can_UpdateAndPublish_Template() + { + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + var template2 = TemplateBuilder.CreateTextPageTemplate("altTemplate"); + await TemplateService.CreateAsync(template2, Constants.Security.SuperUserKey); + + var contentType = ContentTypeBuilder.CreateTextPageContentType(defaultTemplateId: template.Id); + contentType.AllowedTemplates = new[] { template, template2 }; + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + TemplateKey = template.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Template Test" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "Title" }, + new PropertyValueModel { Alias = "bodyText", Value = "Body" } + ], + }; + + var createResult = await ContentEditingService.CreateAndPublishAsync(createModel, [], Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + var content = createResult.Result.Content!; + Assert.AreEqual(template.Id, content.TemplateId); + + // update with different template + var updateModel = new ContentUpdateModel + { + TemplateKey = template2.Key, + Variants = + [ + new VariantModel { Name = "Template Test Updated" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "Updated Title" }, + new PropertyValueModel { Alias = "bodyText", Value = "Updated Body" } + ], + }; + + var result = await ContentEditingService.UpdateAndPublishAsync(content.Key, updateModel, [], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + var updatedContent = await ContentEditingService.GetAsync(content.Key); + Assert.IsNotNull(updatedContent); + Assert.IsTrue(updatedContent.Published); + Assert.AreEqual(template2.Id, updatedContent.TemplateId); + } + + [Test] + public async Task Cannot_UpdateAndPublish_Non_Existing_Content() + { + var updateModel = new ContentUpdateModel + { + Variants = + [ + new VariantModel { Name = "Test" } + ], + }; + + var result = await ContentEditingService.UpdateAndPublishAsync(Guid.NewGuid(), updateModel, [], Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotFound, result.Status); + } + + [Test] + public async Task Cannot_UpdateAndPublish_With_Invalid_Culture() + { + var content = await CreateCultureVariantContent(); + + var updateModel = new ContentUpdateModel + { + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "Invariant" }, + new PropertyValueModel { Alias = "variantTitle", Value = "English", Culture = "en-us" } + ], + Variants = + [ + new VariantModel { Culture = "en-us", Name = "English" } + ], + }; + + var result = await ContentEditingService.UpdateAndPublishAsync(content.Key, updateModel, ["en-us"], Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.InvalidCulture, result.Status); + } + + [Test] + public async Task Can_UpdateAndPublish_Readonly_Property_Is_Preserved() + { + var content = await CreateInvariantContent(); + var labelValue = content.GetValue("label"); + + var updateModel = new ContentUpdateModel + { + Variants = + [ + new VariantModel { Name = "Updated Name" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "Updated title" }, + new PropertyValueModel { Alias = "text", Value = "Updated text" }, + new PropertyValueModel { Alias = "label", Value = "Trying to change label" } + ], + }; + + var result = await ContentEditingService.UpdateAndPublishAsync(content.Key, updateModel, [], Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Result.Content!.Published); + + // re-get and verify the label property was not changed + var updatedContent = await ContentEditingService.GetAsync(content.Key); + Assert.IsNotNull(updatedContent); + Assert.AreEqual(labelValue, updatedContent.GetValue("label")); + Assert.AreEqual("Updated title", updatedContent.GetValue("title")); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 34867fd9f491..1dea3f89709d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -307,5 +307,11 @@ UserServiceTests.cs + + ContentEditingServiceTests.cs + + + ContentEditingServiceTests.cs + From 04c715dd6931940fbab9f845458de935e50db027 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Thu, 21 May 2026 20:22:32 +0200 Subject: [PATCH 03/13] add default implementation --- src/Umbraco.Core/Services/IContentService.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index 2d1c990fd641..aeb1ac32e4a8 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -573,7 +573,12 @@ IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out /// The document to publish. /// The cultures to publish. /// The identifier of the user performing the action. - PublishResult SaveAndPublish(IContent content, string[] culturesToPublish, int userId = Constants.Security.SuperUserId); + // TODO (V19): Remove the default implementation when the method is no longer new. + PublishResult SaveAndPublish(IContent content, string[] culturesToPublish, int userId = Constants.Security.SuperUserId) + { + Save(content, userId); + return Publish(content, culturesToPublish, userId); + } /// /// Publishes a document branch. From 4027f7a51f5b5c4e4c019802c7ba6dfe76709ea1 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Sat, 23 May 2026 12:12:27 +0200 Subject: [PATCH 04/13] PR review fixes --- src/Umbraco.Core/Services/ContentEditingService.cs | 13 +------------ src/Umbraco.Core/Services/IContentEditingService.cs | 8 ++++---- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index fe9b4ab86de6..352b05f43f06 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -417,17 +417,6 @@ private async Task Save(IContent content, Guid us try { var currentUserId = await GetUserIdAsync(userKey); - // PublishResult publishResult = ((ContentService)ContentService).SaveAndPublish(content); - // if (publishResult.Success) - // { - // return ContentEditingOperationStatus.Success; - // } - // - // return publishResult.Result switch - // { - // PublishResultType.FailedPublishCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification, - // _ => ContentEditingOperationStatus.Unknown, - // }; OperationResult saveResult = ContentService.Save(content, currentUserId); return saveResult.Result switch { @@ -436,7 +425,7 @@ private async Task Save(IContent content, Guid us OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification, // for any other state we'll return "unknown" so we know that we need to amend this - _ => ContentEditingOperationStatus.Unknown + _ => ContentEditingOperationStatus.Unknown, }; } catch (Exception ex) diff --git a/src/Umbraco.Core/Services/IContentEditingService.cs b/src/Umbraco.Core/Services/IContentEditingService.cs index bfdede3963d5..2483d23c7e97 100644 --- a/src/Umbraco.Core/Services/IContentEditingService.cs +++ b/src/Umbraco.Core/Services/IContentEditingService.cs @@ -48,9 +48,9 @@ public interface IContentEditingService /// The cultures to publish. /// The unique identifier of the user performing the action. /// An attempt containing the creation result or an error status. - // TODO (V18): Remove default implementation. + // TODO (V19): Remove default implementation. Task> CreateAndPublishAsync(ContentCreateModel createModel, string[] culturesToPublish, Guid userKey) - => Task.FromResult(Attempt.FailWithStatus(ContentEditingOperationStatus.Unknown, new ContentCreateResult())); + => throw new NotImplementedException(); /// /// Updates an existing content item. @@ -69,9 +69,9 @@ Task> CreateAndPubli /// The cultures to publish. /// The unique identifier of the user performing the action. /// An attempt containing the update result or an error status. - // TODO (V18): Remove default implementation. + // TODO (V19): Remove default implementation. Task> UpdateAndPublishAsync(Guid key, ContentUpdateModel updateModel, string[] culturesToPublish, Guid userKey) - => Task.FromResult(Attempt.FailWithStatus(ContentEditingOperationStatus.Unknown, new ContentUpdateResult())); + => throw new NotImplementedException(); /// /// Moves a content item to the recycle bin. From 9137359495c3307cbfb96c906226836248279c28 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 27 May 2026 10:36:43 +0200 Subject: [PATCH 05/13] Fix XML doc formatting. --- .../Document/UpdateAndPublishDocumentController.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs index eed5cc048dff..774eea82eb9e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs @@ -41,7 +41,9 @@ public UpdateAndPublishDocumentController( _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } - /// Updates the specified document with new details provided in the request model, and subsequently publishes the document in the cultures provided. + /// + /// Updates the specified document with new details provided in the request model, and subsequently publishes the document in the cultures provided. + /// /// A token to monitor for cancellation requests. /// The unique identifier of the document to update. /// The model containing the updated document details. From f976bd83060738eb9a7cddfe133ed9f9c276b44b Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 27 May 2026 10:37:12 +0200 Subject: [PATCH 06/13] Move name length check for content in ContentService, now duplicated 4 times, into a single helper method. --- src/Umbraco.Core/Services/ContentService.cs | 31 ++++++++++----------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 3a79081e6aeb..d7aeb6c7497c 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1233,11 +1233,7 @@ public OperationResult Save(IContent content, int? userId = null, ContentSchedul $"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."); - } + EnsureNameLengthIsValid(content); EventMessages eventMessages = EventMessagesFactory.Get(); @@ -1376,10 +1372,7 @@ public PublishResult Publish(IContent content, string[] cultures, int userId = C 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."); - } + EnsureNameLengthIsValid(content); PublishedState publishedState = content.PublishedState; if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) @@ -1450,10 +1443,7 @@ public PublishResult SaveAndPublish(IContent content, string[] culturesToPublish throw new ArgumentNullException(nameof(culturesToPublish)); } - if (content.Name != null && content.Name.Length > 255) - { - throw new InvalidOperationException("Name cannot be more than 255 characters in length."); - } + EnsureNameLengthIsValid(content); var varies = content.ContentType.VariesByCulture(); @@ -1526,10 +1516,7 @@ private PublishResult SaveAndPublish(IContent content, string culture = "*", int } } - if (content.Name != null && content.Name.Length > 255) - { - throw new InvalidOperationException("Name cannot be more than 255 characters in length."); - } + EnsureNameLengthIsValid(content); using ICoreScope scope = ScopeProvider.CreateCoreScope(); scope.WriteLock(Constants.Locks.ContentTree); @@ -3364,6 +3351,16 @@ private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, Ev private static bool HasUnsavedChanges(IContent content) => content.HasIdentity is false || content.IsDirty(); + private const int MaxContentNameLength = 255; + + private static void EnsureNameLengthIsValid(IContent content) + { + if (content.Name?.Length > MaxContentNameLength) + { + throw new InvalidOperationException($"Name cannot be more than {MaxContentNameLength} characters in length."); + } + } + /// /// Checks the data integrity of the content tree and optionally fixes issues. /// From 23f1958304286e36a5d87fb7998c38c900e983c0 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 27 May 2026 10:46:00 +0200 Subject: [PATCH 07/13] Provide a more comprehensive default implementation for save and publish, that checks the save result before publishing. --- src/Umbraco.Core/Services/IContentService.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index aeb1ac32e4a8..10b3ed4ac68a 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -576,8 +576,16 @@ IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out // TODO (V19): Remove the default implementation when the method is no longer new. PublishResult SaveAndPublish(IContent content, string[] culturesToPublish, int userId = Constants.Security.SuperUserId) { - Save(content, userId); - return Publish(content, culturesToPublish, userId); + OperationResult saveResult = Save(content, userId); + if (saveResult.Success) + { + return Publish(content, culturesToPublish, userId); + } + + PublishResultType resultType = saveResult.Result == OperationResultType.FailedCancelledByEvent + ? PublishResultType.FailedPublishCancelledByEvent + : PublishResultType.FailedPublish; + return new PublishResult(resultType, saveResult.EventMessages, content); } /// From 8a26c9d9dafb199454b3edfa7163b3028d815c19 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 27 May 2026 10:55:17 +0200 Subject: [PATCH 08/13] Improved XML doc on the new SaveAndPublish method on IContentService. --- src/Umbraco.Core/Services/IContentService.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index 10b3ed4ac68a..256450389cf0 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -560,18 +560,27 @@ IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out PublishResult Publish(IContent content, string[] cultures, int userId = Constants.Security.SuperUserId); /// - /// Saves and publishes a document. + /// Saves and publishes a document in a single scope. /// /// /// - /// By default, publishes all variations of the document, but it is possible to specify a culture to be - /// published. + /// For invariant content types, must be empty; the document is + /// saved and the invariant culture is published. + /// + /// + /// For variant content types, only the cultures listed in are + /// published. Wildcards ("*"), nulls, whitespace and duplicate entries are not accepted. Passing + /// an empty array saves the document without publishing any culture. /// /// When a culture is being published, it includes all varying values along with all invariant values. - /// The document is *always* saved, even when publishing fails. + /// + /// The save and publish run in the same scope. If publishing fails for a business reason (for example, + /// invalid content or an expired schedule) the save still takes effect; both are skipped only when a + /// saving notification handler cancels the operation. + /// /// /// The document to publish. - /// The cultures to publish. + /// The cultures to publish, or an empty array for invariant content. /// The identifier of the user performing the action. // TODO (V19): Remove the default implementation when the method is no longer new. PublishResult SaveAndPublish(IContent content, string[] culturesToPublish, int userId = Constants.Security.SuperUserId) From 4125fd3e7851886ac567fa710af05c817a9e3143 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 27 May 2026 10:56:06 +0200 Subject: [PATCH 09/13] Align validation on Publish and SaveAndPublish. --- src/Umbraco.Core/Services/ContentService.cs | 68 ++++++++++++------- .../Services/ContentServiceTests.cs | 26 +++++++ 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index d7aeb6c7497c..626475aa724d 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1357,10 +1357,7 @@ public PublishResult Publish(IContent content, string[] cultures, int userId = C 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)); - } + EnsureCulturesAreValid(cultures, nameof(cultures)); cultures = cultures.Select(x => x.EnsureCultureCode()!).ToArray(); @@ -1374,12 +1371,7 @@ public PublishResult Publish(IContent content, string[] cultures, int userId = C EnsureNameLengthIsValid(content); - 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."); - } + EnsurePublishedStateAllowsPublish(content); // cannot accept invariant (null or empty) culture for variant content type // cannot accept a specific culture for invariant content type (but '*' is ok) @@ -1443,13 +1435,32 @@ public PublishResult SaveAndPublish(IContent content, string[] culturesToPublish throw new ArgumentNullException(nameof(culturesToPublish)); } + // wildcards and nulls are not accepted here; cultures must be explicit + if (culturesToPublish.Any(x => x == null || x == "*")) + { + throw new InvalidOperationException( + "Only valid cultures are allowed to be used in this method, wildcards or nulls are not allowed"); + } + + EnsureCulturesAreValid(culturesToPublish, nameof(culturesToPublish)); + + culturesToPublish = culturesToPublish.Select(x => x.EnsureCultureCode()!).ToArray(); + EnsureNameLengthIsValid(content); - var varies = content.ContentType.VariesByCulture(); + EnsurePublishedStateAllowsPublish(content); - if (culturesToPublish.Length == 0 && !varies) + var varies = content.ContentType.VariesByCulture(); + if (varies is false) { - // No cultures specified and doesn't vary, so publish it, else nothing to publish + if (culturesToPublish.Length > 0) + { + throw new ArgumentException( + "Cultures cannot be specified when publishing invariant content types.", + nameof(culturesToPublish)); + } + + // doesn't vary; publish the invariant culture in a single scope alongside the save return SaveAndPublish(content, userId: userId); } @@ -1466,12 +1477,6 @@ public PublishResult SaveAndPublish(IContent content, string[] culturesToPublish return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); } - if (culturesToPublish.Any(x => x == null || x == "*")) - { - throw new InvalidOperationException( - "Only valid cultures are allowed to be used in this method, wildcards or nulls are not allowed"); - } - IEnumerable impacts = culturesToPublish.Select(x => _cultureImpactFactory.ImpactExplicit(x, IsDefaultCulture(allLangs, x))); @@ -1491,12 +1496,7 @@ private PublishResult SaveAndPublish(IContent content, string culture = "*", int { EventMessages evtMsgs = EventMessagesFactory.Get(); - 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."); - } + EnsurePublishedStateAllowsPublish(content); // cannot accept invariant (null or empty) culture for variant content type // cannot accept a specific culture for invariant content type (but '*' is ok) @@ -3361,6 +3361,24 @@ private static void EnsureNameLengthIsValid(IContent content) } } + private static void EnsureCulturesAreValid(string[] cultures, string paramName) + { + if (cultures.Any(c => c.IsNullOrWhiteSpace()) || cultures.Distinct().Count() != cultures.Length) + { + throw new ArgumentException("Cultures cannot be null or whitespace, and must be distinct.", paramName); + } + } + + private static void EnsurePublishedStateAllowsPublish(IContent content) + { + 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."); + } + } + /// /// Checks the data integrity of the content tree and optionally fixes issues. /// diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs index 22b879be487b..dfa25ee34c71 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs @@ -1916,6 +1916,32 @@ public void SaveAndPublish_Rejects_Invalid_Cultures() Assert.Throws(() => ContentService.SaveAndPublish(content, ["en-US", "*", "es-ES"])); } + [Test] + public void SaveAndPublish_Rejects_Whitespace_Cultures() + { + var content = CreateEnglishAndFrenchDocument(out _, out _, out _); + + Assert.Throws(() => ContentService.SaveAndPublish(content, [string.Empty])); + Assert.Throws(() => ContentService.SaveAndPublish(content, [" "])); + Assert.Throws(() => ContentService.SaveAndPublish(content, ["en-US", " "])); + } + + [Test] + public void SaveAndPublish_Rejects_Duplicate_Cultures() + { + var content = CreateEnglishAndFrenchDocument(out var langUk, out _, out _); + + Assert.Throws(() => ContentService.SaveAndPublish(content, [langUk.IsoCode, langUk.IsoCode])); + } + + [Test] + public void SaveAndPublish_Rejects_Cultures_On_Invariant_Content() + { + var content = ContentService.Create("Invariant", -1, "umbTextpage"); + + Assert.Throws(() => ContentService.SaveAndPublish(content, ["en-US"])); + } + [Test] public void SaveAndPublish_No_Cultures_On_Variant_Saves_But_Does_Not_Publish() { From 6a3ccd41c49dad6e2eac2cf972609b7ba5c32331 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 27 May 2026 11:40:05 +0200 Subject: [PATCH 10/13] Removed region from ContentServiceTests (it's the only one, and test names already provide details of the method under test). --- .../Umbraco.Core/Services/ContentServiceTests.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs index dfa25ee34c71..d0de00ff3e29 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs @@ -1733,8 +1733,6 @@ public void Can_Save_And_Publish_Content_And_Child_Without_Identity() Assert.That(childSaved.Success, Is.True); } - #region SaveAndPublish (combined operation) - [Test] public void Can_SaveAndPublish_Invariant_Content() { @@ -2135,8 +2133,6 @@ public async Task Failed_SaveAndPublish_Preserves_Edited_State() }); } - #endregion - [Test] [LongRunning] public void Can_Get_Published_Descendant_Versions() From a5ec965349de538f9ca5bbef485b51bbe4964b82 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 27 May 2026 11:53:12 +0200 Subject: [PATCH 11/13] Add authorization checks for create+publish and update+publish on the combined operation controllers. --- .../CreateAndPublishDocumentController.cs | 20 ++++++++++++++++++- .../UpdateAndPublishDocumentController.cs | 20 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs index abe6caa3f52d..8264a5ba69c4 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateAndPublishDocumentController.cs @@ -5,10 +5,14 @@ using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Models.ContentEditing; 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.Document; @@ -18,6 +22,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document; [ApiVersion("1.0")] public class CreateAndPublishDocumentController : CreateDocumentControllerBase { + private readonly IAuthorizationService _authorizationService; private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; private readonly IContentEditingService _contentEditingService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; @@ -25,7 +30,7 @@ public class CreateAndPublishDocumentController : CreateDocumentControllerBase /// /// Initializes a new instance of the class. /// - /// Service used to authorize access to document creation operations. + /// Service used to authorize access to document creation and publishing operations. /// Factory for creating document editing presentation models. /// Service responsible for content editing functionality. /// Accessor for back office security context. @@ -36,6 +41,7 @@ public CreateAndPublishDocumentController( IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : base(authorizationService) { + _authorizationService = authorizationService; _documentEditingPresentationFactory = documentEditingPresentationFactory; _contentEditingService = contentEditingService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; @@ -59,6 +65,18 @@ public async Task Create( CreateAndPublishDocumentRequestModel requestModel) => await HandleRequest(requestModel, async () => { + // The base HandleRequest verifies the user can create under the parent. + // Creating-and-publishing additionally requires publish permission, so we check that here. + AuthorizationResult publishAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionPublish.ActionLetter, requestModel.Parent?.Id, requestModel.CulturesToPublish), + AuthorizationPolicies.ContentPermissionByResource); + + if (publishAuthorizationResult.Succeeded is false) + { + return Forbidden(); + } + ContentCreateModel model = _documentEditingPresentationFactory.MapCreateModel(requestModel); Attempt result = await _contentEditingService.CreateAndPublishAsync(model, requestModel.CulturesToPublish, CurrentUserKey(_backOfficeSecurityAccessor)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs index 774eea82eb9e..16410791ec99 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateAndPublishDocumentController.cs @@ -5,10 +5,14 @@ using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Models.ContentEditing; 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.Document; @@ -18,6 +22,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document; [ApiVersion("1.0")] public class UpdateAndPublishDocumentController : UpdateDocumentControllerBase { + private readonly IAuthorizationService _authorizationService; private readonly IContentEditingService _contentEditingService; private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; @@ -25,7 +30,7 @@ public class UpdateAndPublishDocumentController : UpdateDocumentControllerBase /// /// Initializes a new instance of the class. /// - /// Service for verifying user permissions. + /// Service for verifying user permissions to update and publish. /// Service for managing content updates. /// Factory for creating document editing presentation models. /// Accessor for the back office user security context. @@ -36,6 +41,7 @@ public UpdateAndPublishDocumentController( IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : base(authorizationService) { + _authorizationService = authorizationService; _contentEditingService = contentEditingService; _documentEditingPresentationFactory = documentEditingPresentationFactory; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; @@ -61,6 +67,18 @@ public async Task Update( UpdateAndPublishDocumentRequestModel requestModel) => await HandleRequest(id, requestModel, async () => { + // The base HandleRequest verifies the user can update the document. + // Updating-and-publishing additionally requires publish permission, so we check that here. + AuthorizationResult publishAuthorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionPublish.ActionLetter, id, requestModel.CulturesToPublish), + AuthorizationPolicies.ContentPermissionByResource); + + if (publishAuthorizationResult.Succeeded is false) + { + return Forbidden(); + } + ContentUpdateModel model = _documentEditingPresentationFactory.MapUpdateModel(requestModel); Guid currentUserKey = CurrentUserKey(_backOfficeSecurityAccessor); Attempt result = await _contentEditingService.UpdateAndPublishAsync(id, model, requestModel.CulturesToPublish, currentUserKey); From 5801f2c1c421cd167d38a5a73bc95353081672c5 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 27 May 2026 12:01:01 +0200 Subject: [PATCH 12/13] Added authorization integration tests. --- ...CreateAndPublishDocumentControllerTests.cs | 90 +++++++++++++++++ ...UpdateAndPublishDocumentControllerTests.cs | 97 +++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Document/CreateAndPublishDocumentControllerTests.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Document/UpdateAndPublishDocumentControllerTests.cs diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/CreateAndPublishDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/CreateAndPublishDocumentControllerTests.cs new file mode 100644 index 000000000000..64a33c6202fe --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/CreateAndPublishDocumentControllerTests.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.Document; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Document; + +public class CreateAndPublishDocumentControllerTests : ManagementApiUserGroupTestBase +{ + private ITemplateService TemplateService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _templateKey; + private Guid _contentTypeKey; + + [SetUp] + public async Task Setup() + { + // Template + var template = TemplateBuilder.CreateTextPageTemplate(Guid.NewGuid().ToString()); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + _templateKey = template.Key; + + // Content Type + var contentType = ContentTypeBuilder.CreateTextPageContentType(defaultTemplateId: template.Id, name: Guid.NewGuid().ToString(), alias: Guid.NewGuid().ToString()); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + _contentTypeKey = contentType.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 + }; + + // Writers can create but not publish, so the combined operation must be forbidden for them. + protected override UserGroupAssertionModel WriterUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Unauthorized + }; + + protected override async Task ClientRequest() + { + CreateAndPublishDocumentRequestModel createAndPublishDocumentRequestModel = new() + { + Template = new ReferenceByIdModel(_templateKey), + DocumentType = new ReferenceByIdModel(_contentTypeKey), + Parent = null, + Id = Guid.NewGuid(), + Values = [], + Variants = + [ + new() { Culture = null, Segment = null, Name = "The en-US name", }, + ], + CulturesToPublish = Array.Empty(), + }; + + return await Client.PostAsync(Url, JsonContent.Create(createAndPublishDocumentRequestModel)); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/UpdateAndPublishDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/UpdateAndPublishDocumentControllerTests.cs new file mode 100644 index 000000000000..acfaeb7b2a53 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/UpdateAndPublishDocumentControllerTests.cs @@ -0,0 +1,97 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Document; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Document; + +public class UpdateAndPublishDocumentControllerTests : ManagementApiUserGroupTestBase +{ + private IContentEditingService ContentEditingService => GetRequiredService(); + + private ITemplateService TemplateService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _templateKey; + private Guid _documentKey; + + [SetUp] + public async Task Setup() + { + // Template + var template = TemplateBuilder.CreateTextPageTemplate(Guid.NewGuid().ToString()); + var templateResponse = await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + _templateKey = templateResponse.Result.Key; + + // Content Type + var contentType = ContentTypeBuilder.CreateTextPageContentType(defaultTemplateId: template.Id, name: Guid.NewGuid().ToString(), alias: Guid.NewGuid().ToString()); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Content + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + TemplateKey = _templateKey, + ParentKey = Constants.System.RootKey, + Variants = [new() { Name = Guid.NewGuid().ToString() }], + }; + var response = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + _documentKey = response.Result.Content.Key; + } + + protected override Expression> MethodSelector => + x => x.Update(CancellationToken.None, _documentKey, 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 + }; + + // Writers can update but not publish, so the combined operation must be forbidden for them. + protected override UserGroupAssertionModel WriterUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Unauthorized + }; + + protected override async Task ClientRequest() + { + UpdateAndPublishDocumentRequestModel updateAndPublishDocumentRequestModel = new() + { + Variants = + [ + new() { Culture = null, Segment = null, Name = "The new name", }, + ], + CulturesToPublish = [], + }; + + return await Client.PutAsync(Url, JsonContent.Create(updateAndPublishDocumentRequestModel)); + } +} From 5ed6da6b78112b271d70c4c279b04332b5d0e7da Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:13:32 +0200 Subject: [PATCH 13/13] Documents: Use single-transaction create/update-and-publish endpoints (FE) (#23031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Documents: Use single-transaction create/update-and-publish endpoints (FE) Replace the separate save+publish calls on the document save-and-publish path with the new create-and-publish / update-and-publish endpoints, and keep unpublished but edited variants dirty after reload via a current-state re-merge. Co-Authored-By: Claude Opus 4.8 (1M context) * Documents: Address review feedback on save-and-publish FE - Deduplicate request-body mapping in the document data source (shared #mapCreateRequestBody / #mapUpdateRequestBody) to remove create vs create-and-publish duplication (CodeScene). - Split performCreateOrUpdateAndPublish into #createAndPublish / #updateAndPublish to reduce method complexity (CodeScene), and clarify its post-condition in JSDoc. - Default parentUnique to null on the repository createAndPublish overload (Claude). - De-duplicate segments before expanding variant lists (Copilot). - Add a createAndPublish data-source unit test (Copilot). Co-Authored-By: Claude Opus 4.8 (1M context) * Documents: Drop redundant document re-read on save-and-publish The create/update-and-publish data-source methods re-read the full document after the mutation, but the publishing workspace context immediately calls reload() (which re-fetches and refreshes state) and discards that result. Remove the data-source re-read so save-and-publish fetches the document once (the reload) instead of twice. Co-Authored-By: Claude Opus 4.8 (1M context) * Documents: Fix spurious discard dialog after create-and-publish After create-and-publish the workspace flipped isNew=false before its data state was reconciled (that happens in the subsequent reload + transfer). The flip schedules the new->edit redirect, whose navigation guard then saw a transient dirty state and popped a "Discard unsaved changes" dialog — most visible on an invariant document with an empty RTE (reported by Andy Butland). Reconcile persisted to the just-saved data before flipping isNew. Using saveData (not the full current data) keeps published variants clean while edited-but-unpublished variants stay dirty, so their edits remain preserved and navigation-guarded during the brief pre-reload window. Update-and-publish is unaffected (no redirect). Co-Authored-By: Claude Opus 4.8 * Documents: Move create/update-and-publish into the publishing domain Following review feedback (Mads Rasmussen), keep all publishing-related HTTP in the publishing repository/data source rather than the document detail layer: - Move createAndPublish/updateAndPublish from UmbDocumentServerDataSource + UmbDocumentDetailRepository to UmbDocumentPublishingServerDataSource + UmbDocumentPublishingRepository. Extract the shared create/update request-body mapping to document-detail-request.mappers.ts so it is not duplicated. - The publishing workspace context orchestrates the combined call and asks the document workspace context to apply the create/update lifecycle via new public finalizeCreate/finalizeUpdate methods. This keeps setIsNew and _workspaceEventUnique private — no shared workspace base-class changes. - finalizeCreate carries the create-and-publish fix (reconcile persisted to the saved data before the new->edit redirect) so an edited-but-unpublished variant stays dirty while published variants are clean. Move the and-publish data-source test to the publishing folder; rework the save-and-publish/create-and-publish context tests to drive the publishing data source + finalize methods. Co-Authored-By: Claude Opus 4.8 * Documents: Drop redundant context guard in save-and-publish orchestrator #createOrUpdateAndPublish re-checked #documentWorkspaceContext although its only caller (#performSaveAndPublish) already guards it; pass the narrowed reference in. Co-Authored-By: Claude Opus 4.8 * Documents: Reconcile current as well as persisted in finalizeCreate Andy Butland found the create-and-publish "Discard unsaved changes" dialog still appeared for some document types. The dirty guard's jsonStringComparison is order-sensitive, and savedData (a merge-processed projection of the draft) can order its values array differently from current — so reconciling only persisted still left a spurious mismatch. Set both persisted and current to savedData before flipping isNew, so the new->edit redirect can never observe a dirty state. Edited-but-unpublished variants are restored (dirty again) by the caller's reload + transferPublishedVariantsToCurrent. Update the create-and-publish "no data loss" test to assert the end state (after reload + transfer) instead of the intermediate redirect window, since the unpublished variant's edit is briefly absent during it by design. Diagnosis and fix proposed by Andy Butland. Co-Authored-By: Claude Opus 4.8 * Documents: Align finalizeUpdate with finalizeCreate and tighten method docs Review feedback from Niels: finalizeUpdate now takes the saved data and reconciles persisted/current like finalizeCreate (mirroring the base _update), the finalize*/transfer JSDocs describe only the method's own responsibility, and the transfer parameter is renamed to currentData to match the method name. Co-Authored-By: Claude Opus 4.8 * initial correction * second round of refactor * url-pattern-to-string tests * url-pattern-to-string jsdocs * avoid discard changes dialog when navigating between create and edit * keep track of the absolute route as well * check absolute path as part of dirty check * check navigation util * added TODOs * clean up * error handling * error handling for schedule * remove unused import * rename to performDefault... * rename * rename and export interface * simplify #applyPersistedData * fix(documents): avoid double notification on publish/schedule validation failure The validation-failure path already notifies and re-rejects; the shared top-level .catch then fired a second, contradictory toast ("saved for you" followed by "could not be saved"). Scope the failure notification to the publish path so each outcome shows a single message. Co-Authored-By: Claude Opus 4.8 * fix(documents): don't report publish failure when only the read-back fails After a successful create/update-and-publish the workspace re-reads the document (the endpoints return key-only). If that read-back fails, the publish has already succeeded server-side, so falling through to the publish-failed toast is misleading. Fall back to the submitted data so the workspace lifecycle completes, and surface a soft warning that the editor could not be refreshed. Also widen the loadWithoutPersist() error message from "document" to "entity" since the method lives on the shared entity-detail base. Co-Authored-By: Claude Opus 4.8 * simplify comment --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Niels Lyngsø --- .../mocks/db/document-publishing.manager.ts | 35 +++- .../msw-handlers/document/detail.handlers.ts | 30 +++ .../src/assets/lang/en.ts | 4 +- .../workspace/block-workspace.context.ts | 6 +- .../content-detail-workspace-base.ts | 178 +++++++++--------- .../src/packages/core/backend-api/sdk.gen.ts | 46 ++++- .../packages/core/backend-api/types.gen.ts | 89 +++++++++ .../url-pattern-to-string.function.test.ts | 61 ++++++ .../path/url-pattern-to-string.function.ts | 9 +- .../workspace-route-manager.controller.ts | 23 ++- .../entity-detail-workspace-base.ts | 33 ++-- .../src/packages/core/workspace/index.ts | 1 + .../check-will-navigate-away.function.test.ts | 73 +++++++ .../check-will-navigate-away.function.ts | 41 ++++ .../document-publishing.repository.ts | 32 ++++ ...ment-publishing.server.data-source.test.ts | 98 ++++++++++ .../document-publishing.server.data-source.ts | 67 +++++++ .../document-publishing.workspace-context.ts | 123 +++++++----- .../detail/document-detail-request.mappers.ts | 40 ++++ .../detail/document-detail.repository.ts | 5 +- .../document-detail.server.data-source.ts | 18 +- ...rkspace-create-and-publish.context.test.ts | 110 +++++++++++ ...workspace-save-and-publish.context.test.ts | 124 ++++++++++++ 23 files changed, 1073 insertions(+), 173 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/utils/path/url-pattern-to-string.function.test.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/workspace/utils/check-will-navigate-away.function.test.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/workspace/utils/check-will-navigate-away.function.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.server.data-source.test.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail-request.mappers.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-create-and-publish.context.test.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-save-and-publish.context.test.ts diff --git a/src/Umbraco.Web.UI.Client/mocks/db/document-publishing.manager.ts b/src/Umbraco.Web.UI.Client/mocks/db/document-publishing.manager.ts index 67d8f54ba498..cf61b5ba8abb 100644 --- a/src/Umbraco.Web.UI.Client/mocks/db/document-publishing.manager.ts +++ b/src/Umbraco.Web.UI.Client/mocks/db/document-publishing.manager.ts @@ -1,9 +1,11 @@ import type { UmbMockDocumentModel } from '../data/mock-data-set.types.js'; import type { UmbDocumentMockDB } from './document.db.js'; import type { + CreateAndPublishDocumentRequestModel, PublishDocumentRequestModel, PublishDocumentWithDescendantsRequestModel, UnpublishDocumentRequestModel, + UpdateAndPublishDocumentRequestModel, } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbId } from '@umbraco-cms/backoffice/id'; import type { DocumentVariantResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; @@ -55,6 +57,34 @@ export class UmbMockDocumentPublishingManager { this.#documentDb.detail.update(id, document); } + createAndPublish(data: CreateAndPublishDocumentRequestModel) { + const id = this.#documentDb.detail.create(data); + this.#publishCultures(id, data.culturesToPublish); + return id; + } + + updateAndPublish(id: string, data: UpdateAndPublishDocumentRequestModel) { + this.#documentDb.detail.update(id, data); + this.#publishCultures(id, data.culturesToPublish); + } + + #publishCultures(id: string, culturesToPublish: Array) { + const document: UmbMockDocumentModel = this.#documentDb.detail.read(id); + + // Invariant content types publish with an empty cultures array; publish the invariant variant in that case. + const cultures: Array = culturesToPublish.length > 0 ? culturesToPublish : [null]; + + cultures.forEach((culture) => { + const variant = document.variants.find((x) => x.culture === culture); + if (variant) { + variant.state = 'Published' as UmbDocumentVariantState; + variant.updateDate = new Date().toISOString(); + } + }); + + this.#documentDb.detail.update(id, document); + } + publishWithDescendants(id: string, data: PublishDocumentWithDescendantsRequestModel) { const document: UmbMockDocumentModel = this.#documentDb.detail.read(id); const documents = this.getDescendants(id, []); @@ -64,10 +94,7 @@ export class UmbMockDocumentPublishingManager { for (const culture of data.cultures) { for (const d of documents) { const variant = document.variants.find((x) => x.culture === culture); - if ( - variant && - (data.includeUnpublishedDescendants || variant.state !== 'Published') - ) { + if (variant && (data.includeUnpublishedDescendants || variant.state !== 'Published')) { variant.state = 'Published' as UmbDocumentVariantState; variant.updateDate = new Date().toISOString(); } diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/detail.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/detail.handlers.ts index c9d891ea1d77..5f12b4f463f2 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/detail.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/detail.handlers.ts @@ -4,11 +4,13 @@ import { umbMockManager } from '../../mock-manager.js'; import { umbDocumentMockDb } from '../../db/document.db.js'; import { UMB_SLUG } from './slug.js'; import type { + CreateAndPublishDocumentRequestModel, CreateDocumentRequestModel, DefaultReferenceResponseModel, GetDocumentByIdAvailableSegmentOptionsResponse, GetDocumentByIdReferencedDescendantsResponse, PagedIReferenceResponseModel, + UpdateAndPublishDocumentRequestModel, UpdateDocumentRequestModel, } from '@umbraco-cms/backoffice/external/backend-api'; import { umbracoPath } from '@umbraco-cms/backoffice/utils'; @@ -38,6 +40,21 @@ export const detailHandlers = [ }); }), + http.post(umbracoPath(`${UMB_SLUG}/create-and-publish`), async ({ request }) => { + const requestBody = (await request.json()) as CreateAndPublishDocumentRequestModel; + if (!requestBody) return new HttpResponse(null, { status: 400, statusText: 'no body found' }); + + const id = umbDocumentMockDb.publishing.createAndPublish(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(umbDocumentMockDb.getConfiguration()); }), @@ -155,6 +172,19 @@ export const detailHandlers = [ } }), + http.put(umbracoPath(`${UMB_SLUG}/:id/update-and-publish`), async ({ request, params }) => { + const id = params.id as string; + if (!id) return new HttpResponse(null, { status: 400 }); + if (id === 'forbidden') { + // Simulate a forbidden response + return new HttpResponse(null, { status: 403 }); + } + const requestBody = (await request.json()) as UpdateAndPublishDocumentRequestModel; + if (!requestBody) return new HttpResponse(null, { status: 400, statusText: 'no body found' }); + umbDocumentMockDb.publishing.updateAndPublish(id, requestBody); + return new HttpResponse(null, { status: 200 }); + }), + http.put(umbracoPath(`${UMB_SLUG}/:id`), async ({ request, params }) => { const id = params.id as string; if (!id) return new HttpResponse(null, { status: 400 }); 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 19ed8f268343..bef5871132ff 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -1542,9 +1542,11 @@ export default { cssSavedText: 'Stylesheet saved without any errors', dataTypeSaved: 'Datatype saved', dictionaryItemSaved: 'Dictionary item saved', + editContentPublishedFailed: 'Document could not be published or saved', 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', + editContentPublishedReloadFailed: 'Document published, but the editor could not be refreshed', editContentPublishedText: 'and is visible on the website', editContentUnpublishedHeader: 'Document unpublished', editContentUnpublishedText: 'and is no longer visible on the website', @@ -2192,7 +2194,7 @@ export default { updateDate: 'User last updated', userCreated: 'has been created', userCreatedSuccessHelp: 'The new user has successfully been created. To log in to Umbraco use the password below.', - userCreatedApiSuccessHelp: 'Set client credentials for the account via the user\'s profile.', + userCreatedApiSuccessHelp: "Set client credentials for the account via the user's profile.", userHasPassword: 'The user already has a password set', userHasGroup: "The user is already in group '%0%'", userLockoutNotEnabled: 'Lockout is not enabled for this user', diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts index 977f033ea405..2c69f15ea5e3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts @@ -12,6 +12,7 @@ import { UmbWorkspaceIsNewRedirectController, type ManifestWorkspace, UmbWorkspaceIsNewRedirectControllerAlias, + umbWorkspaceWillNavigateAway, } from '@umbraco-cms/backoffice/workspace'; import { UmbBooleanState, @@ -362,10 +363,7 @@ export class UmbBlockWorkspaceContext, UmbContentVariantPickerValue>; } +/** + * Interface for the third argument of performCreateOrUpdate, relevant if the persistence method should be different from default. + */ +export interface UmbContentWorkspaceCreateOrUpdatePersistMethods { + create?: ( + saveData: DetailModelType, + variantIds: Array, + parent: UmbEntityModel, + ) => Promise; + update?: (saveData: DetailModelType, variantIds: Array) => Promise; +} + /** * The base class for a content detail workspace context. * @exports @@ -96,16 +110,16 @@ export interface UmbContentDetailWorkspaceContextArgs< * @template CreateArgsType */ export abstract class UmbContentDetailWorkspaceContextBase< - DetailModelType extends UmbContentDetailModel, - DetailRepositoryType extends UmbDetailRepository = UmbDetailRepository, - ContentTypeDetailModelType extends UmbContentTypeDetailModel = UmbContentTypeDetailModel, - VariantModelType extends UmbEntityVariantModel = DetailModelType extends { variants: UmbEntityVariantModel[] } - ? DetailModelType['variants'][0] - : never, - VariantOptionModelType extends UmbEntityVariantOptionModel = UmbEntityVariantOptionModel, - CreateArgsType extends - UmbEntityDetailWorkspaceContextCreateArgs = UmbEntityDetailWorkspaceContextCreateArgs, - > + DetailModelType extends UmbContentDetailModel, + DetailRepositoryType extends UmbDetailRepository = UmbDetailRepository, + ContentTypeDetailModelType extends UmbContentTypeDetailModel = UmbContentTypeDetailModel, + VariantModelType extends UmbEntityVariantModel = DetailModelType extends { variants: UmbEntityVariantModel[] } + ? DetailModelType['variants'][0] + : never, + VariantOptionModelType extends UmbEntityVariantOptionModel = UmbEntityVariantOptionModel, + CreateArgsType extends UmbEntityDetailWorkspaceContextCreateArgs = + UmbEntityDetailWorkspaceContextCreateArgs, +> extends UmbEntityDetailWorkspaceContextBase implements UmbContentWorkspaceContext, @@ -1060,119 +1074,113 @@ export abstract class UmbContentDetailWorkspaceContextBase< * Perform the create or update of the content * @param {Array} variantIds - The variant ids to save * @param {DetailModelType} saveData - The data to save + * @param {UmbContentWorkspaceCreateOrUpdatePersistMethods} [persistenceMethod] - Optional custom persistence logic. * @memberof UmbContentDetailWorkspaceContextBase */ - public async performCreateOrUpdate(variantIds: Array, saveData: DetailModelType) { + public async performCreateOrUpdate( + variantIds: Array, + saveData: DetailModelType, + persistenceMethod?: UmbContentWorkspaceCreateOrUpdatePersistMethods, + ) { if (this.getIsNew()) { - await this.#create(variantIds, saveData); + await this.#create(variantIds, saveData, persistenceMethod?.create); } else { - await this.#update(variantIds, saveData); + await this.#update(variantIds, saveData, persistenceMethod?.update); } } - async #create(variantIds: Array, saveData: DetailModelType) { - if (!this._detailRepository) throw new Error('Detail repository is not set'); - + async #create( + variantIds: Array, + saveData: DetailModelType, + overwriteCreate?: UmbContentWorkspaceCreateOrUpdatePersistMethods['create'], + ) { const parent = this._internal_getCreateUnderParent(); if (!parent) throw new Error('Parent is not set'); - const { data, error } = await this._detailRepository.create(saveData, parent.unique); - if (!data || error) { - throw new Error('Error creating content'); - } - - const variantIdsIncludingInvariant = [...variantIds, UmbVariantId.CreateInvariant()]; + const persisted = overwriteCreate + ? await overwriteCreate(saveData, variantIds, parent) + : await this.#defaultCreatePersistence(saveData, parent.unique); - // Only update the variants that was chosen to be saved: - const persistedData = this._data.getCurrent(); - const newPersistedData = await new UmbMergeContentVariantDataController(this).process( - persistedData, - data, - variantIds, - variantIdsIncludingInvariant, - ); - this._data.setPersisted(newPersistedData); - - // Only update the variants that was chosen to be saved: - const currentData = this._data.getCurrent(); - const newCurrentData = await new UmbMergeContentVariantDataController(this).process( - currentData, - data, - variantIds, - variantIdsIncludingInvariant, - ); - this._data.setCurrent(newCurrentData); + // Set persisted AND current before flipping isNew: the flip triggers the new->edit redirect, + // whose navigation guard compares the two states with an order-sensitive comparison. + await this.#applyPersistedData(persisted, variantIds); this.setIsNew(false); - const eventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); - if (!eventContext) { - throw new Error('Event context is missing'); - } - - const reloadStructureEvent = new UmbRequestReloadStructureForEntityEvent({ - entityType: parent.entityType, - unique: parent.unique, - }); + await this.#dispatchActionEvents( + new UmbRequestReloadStructureForEntityEvent({ entityType: parent.entityType, unique: parent.unique }), + new UmbRequestReloadChildrenOfEntityEvent({ entityType: parent.entityType, unique: parent.unique }), + ); + } - eventContext.dispatchEvent(reloadStructureEvent); + async #update( + variantIds: Array, + saveData: DetailModelType, + overwriteUpdate?: UmbContentWorkspaceCreateOrUpdatePersistMethods['update'], + ) { + const persisted = overwriteUpdate + ? await overwriteUpdate(saveData, variantIds) + : await this.#defaultUpdatePersistence(saveData); - const reloadChildrenEvent = new UmbRequestReloadChildrenOfEntityEvent({ - entityType: parent.entityType, - unique: parent.unique, - }); + await this.#applyPersistedData(persisted, variantIds); - eventContext.dispatchEvent(reloadChildrenEvent); + const unique = this.getUnique(); + if (!unique) { + return; + } + const entityType = this.getEntityType(); + await this.#dispatchActionEvents( + new UmbRequestReloadStructureForEntityEvent({ unique, entityType }), + new UmbEntityUpdatedEvent({ unique, entityType, eventUnique: this._workspaceEventUnique }), + ); } - async #update(variantIds: Array, saveData: DetailModelType) { + async #defaultCreatePersistence(saveData: DetailModelType, parentUnique: string | null): Promise { if (!this._detailRepository) throw new Error('Detail repository is not set'); + const { data, error } = await this._detailRepository.create(saveData, parentUnique); + if (!data || error) throw new Error('Error creating content'); + return data; + } + async #defaultUpdatePersistence(saveData: DetailModelType): Promise { + if (!this._detailRepository) throw new Error('Detail repository is not set'); const { data, error } = await this._detailRepository.save(saveData); - if (!data || error) { - throw new Error('Error saving content'); + if (!data || error) throw new Error('Error saving content'); + return data; + } + + /** + * Partial update the current data with the persisted data, only for the variants that were saved. + * @param {DetailModelType | undefined} persisted - The saved document, or undefined if not returned + * @param {Array} variantIds - The variants that were saved + */ + async #applyPersistedData(persisted: DetailModelType | undefined, variantIds: Array) { + if (!persisted) { + throw new Error('Persisted data is missing, persistence methods should return latest draft from the server.'); } const variantIdsIncludingInvariant = [...variantIds, UmbVariantId.CreateInvariant()]; - // Only update the variants that was chosen to be saved: - // Use getPersisted() as the base so non-saved variants retain the actual - // server state, not unsaved local edits from current data. - const persistedData = this._data.getPersisted(); const newPersistedData = await new UmbMergeContentVariantDataController(this).process( - persistedData, - data, + this._data.getPersisted(), + persisted, variantIds, variantIdsIncludingInvariant, ); this._data.setPersisted(newPersistedData); - // Only update the variants that was chosen to be saved: - const currentData = this._data.getCurrent(); const newCurrentData = await new UmbMergeContentVariantDataController(this).process( - currentData, - data, + this._data.getCurrent(), + persisted, variantIds, variantIdsIncludingInvariant, ); this._data.setCurrent(newCurrentData); + } - const unique = this.getUnique()!; - const entityType = this.getEntityType(); - + async #dispatchActionEvents(...events: Array) { const eventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); - if (!eventContext) { - throw new Error('Event context is missing'); - } - const structureEvent = new UmbRequestReloadStructureForEntityEvent({ unique, entityType }); - eventContext.dispatchEvent(structureEvent); - - const updatedEvent = new UmbEntityUpdatedEvent({ - unique, - entityType, - eventUnique: this._workspaceEventUnique, - }); - - eventContext.dispatchEvent(updatedEvent); + if (!eventContext) throw new Error('Event context is missing'); + events.forEach((event) => eventContext.dispatchEvent(event)); } override resetState() { 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 e68b0957e9d6..c18f7224ae90 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, DeleteUserCurrentAvatarData, DeleteUserCurrentAvatarErrors, DeleteUserCurrentAvatarResponses, 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, GetDataTypeBatchData, GetDataTypeBatchErrors, GetDataTypeBatchResponses, GetDataTypeByIdData, GetDataTypeByIdErrors, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedErrors, GetDataTypeByIdIsUsedResponses, GetDataTypeByIdReferencedByData, GetDataTypeByIdReferencedByErrors, GetDataTypeByIdReferencedByResponses, GetDataTypeByIdResponses, GetDataTypeByIdSchemaData, GetDataTypeByIdSchemaErrors, GetDataTypeByIdSchemaResponses, GetDataTypeConfigurationData, GetDataTypeConfigurationErrors, GetDataTypeConfigurationResponses, GetDataTypeFolderByIdData, GetDataTypeFolderByIdErrors, GetDataTypeFolderByIdResponses, GetDataTypeSchemasBatchData, GetDataTypeSchemasBatchErrors, GetDataTypeSchemasBatchResponses, GetDictionaryByIdData, GetDictionaryByIdErrors, GetDictionaryByIdExportData, GetDictionaryByIdExportErrors, GetDictionaryByIdExportResponses, GetDictionaryByIdResponses, GetDictionaryData, GetDictionaryErrors, GetDictionaryResponses, GetDocumentAreReferencedData, GetDocumentAreReferencedErrors, GetDocumentAreReferencedResponses, GetDocumentBlueprintByIdAuditLogData, GetDocumentBlueprintByIdAuditLogErrors, GetDocumentBlueprintByIdAuditLogResponses, 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, GetDocumentTypeBatchData, GetDocumentTypeBatchErrors, GetDocumentTypeBatchResponses, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenErrors, GetDocumentTypeByIdAllowedChildrenResponses, GetDocumentTypeByIdAllowedParentsData, GetDocumentTypeByIdAllowedParentsErrors, GetDocumentTypeByIdAllowedParentsResponses, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintErrors, GetDocumentTypeByIdBlueprintResponses, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesErrors, GetDocumentTypeByIdCompositionReferencesResponses, GetDocumentTypeByIdData, GetDocumentTypeByIdErrors, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportErrors, GetDocumentTypeByIdExportResponses, GetDocumentTypeByIdResponses, GetDocumentTypeByIdSchemaData, GetDocumentTypeByIdSchemaErrors, GetDocumentTypeByIdSchemaResponses, 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, GetItemDataTypeAncestorsData, GetItemDataTypeAncestorsErrors, GetItemDataTypeAncestorsResponses, GetItemDataTypeData, GetItemDataTypeErrors, GetItemDataTypeResponses, GetItemDataTypeSearchData, GetItemDataTypeSearchErrors, GetItemDataTypeSearchResponses, GetItemDictionaryData, GetItemDictionaryErrors, GetItemDictionaryResponses, GetItemDocumentAncestorsData, GetItemDocumentAncestorsErrors, GetItemDocumentAncestorsResponses, GetItemDocumentBlueprintData, GetItemDocumentBlueprintErrors, GetItemDocumentBlueprintResponses, GetItemDocumentData, GetItemDocumentErrors, GetItemDocumentResponses, GetItemDocumentSearchData, GetItemDocumentSearchErrors, GetItemDocumentSearchResponses, GetItemDocumentTypeAncestorsData, GetItemDocumentTypeAncestorsErrors, GetItemDocumentTypeAncestorsResponses, GetItemDocumentTypeData, GetItemDocumentTypeErrors, GetItemDocumentTypeResponses, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchErrors, GetItemDocumentTypeSearchResponses, GetItemLanguageData, GetItemLanguageDefaultData, GetItemLanguageDefaultErrors, GetItemLanguageDefaultResponses, GetItemLanguageErrors, GetItemLanguageResponses, GetItemMediaAncestorsData, GetItemMediaAncestorsErrors, GetItemMediaAncestorsResponses, GetItemMediaData, GetItemMediaErrors, GetItemMediaResponses, GetItemMediaSearchData, GetItemMediaSearchErrors, GetItemMediaSearchResponses, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedErrors, GetItemMediaTypeAllowedResponses, GetItemMediaTypeAncestorsData, GetItemMediaTypeAncestorsErrors, GetItemMediaTypeAncestorsResponses, GetItemMediaTypeData, GetItemMediaTypeErrors, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersErrors, GetItemMediaTypeFoldersResponses, GetItemMediaTypeResponses, GetItemMediaTypeSearchData, GetItemMediaTypeSearchErrors, GetItemMediaTypeSearchResponses, GetItemMemberAncestorsData, GetItemMemberAncestorsErrors, GetItemMemberAncestorsResponses, GetItemMemberData, GetItemMemberErrors, GetItemMemberGroupData, GetItemMemberGroupErrors, GetItemMemberGroupResponses, GetItemMemberResponses, GetItemMemberSearchData, GetItemMemberSearchErrors, GetItemMemberSearchResponses, GetItemMemberTypeAncestorsData, GetItemMemberTypeAncestorsErrors, GetItemMemberTypeAncestorsResponses, GetItemMemberTypeData, GetItemMemberTypeErrors, GetItemMemberTypeResponses, GetItemMemberTypeSearchData, GetItemMemberTypeSearchErrors, GetItemMemberTypeSearchResponses, GetItemPartialViewData, GetItemPartialViewErrors, GetItemPartialViewResponses, GetItemRelationTypeData, GetItemRelationTypeErrors, GetItemRelationTypeResponses, GetItemScriptData, GetItemScriptErrors, GetItemScriptResponses, GetItemStaticFileData, GetItemStaticFileErrors, GetItemStaticFileResponses, GetItemStylesheetData, GetItemStylesheetErrors, GetItemStylesheetResponses, GetItemTemplateAncestorsData, GetItemTemplateAncestorsErrors, GetItemTemplateAncestorsResponses, 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, GetMediaTypeBatchData, GetMediaTypeBatchErrors, GetMediaTypeBatchResponses, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenErrors, GetMediaTypeByIdAllowedChildrenResponses, GetMediaTypeByIdAllowedParentsData, GetMediaTypeByIdAllowedParentsErrors, GetMediaTypeByIdAllowedParentsResponses, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesErrors, GetMediaTypeByIdCompositionReferencesResponses, GetMediaTypeByIdData, GetMediaTypeByIdErrors, GetMediaTypeByIdExportData, GetMediaTypeByIdExportErrors, GetMediaTypeByIdExportResponses, GetMediaTypeByIdResponses, GetMediaTypeByIdSchemaData, GetMediaTypeByIdSchemaErrors, GetMediaTypeByIdSchemaResponses, 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, GetMemberTypeAllowedAtRootData, GetMemberTypeAllowedAtRootErrors, GetMemberTypeAllowedAtRootResponses, GetMemberTypeBatchData, GetMemberTypeBatchErrors, GetMemberTypeBatchResponses, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesErrors, GetMemberTypeByIdCompositionReferencesResponses, GetMemberTypeByIdData, GetMemberTypeByIdErrors, GetMemberTypeByIdExportData, GetMemberTypeByIdExportErrors, GetMemberTypeByIdExportResponses, GetMemberTypeByIdResponses, GetMemberTypeByIdSchemaData, GetMemberTypeByIdSchemaErrors, GetMemberTypeByIdSchemaResponses, 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, GetTreeDataTypeSearchData, GetTreeDataTypeSearchErrors, GetTreeDataTypeSearchResponses, 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, GetTreeDocumentTypeSearchData, GetTreeDocumentTypeSearchErrors, GetTreeDocumentTypeSearchResponses, 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, PatchDocumentByIdPatchData, PatchDocumentByIdPatchErrors, PatchDocumentByIdPatchResponses, 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, PutUserCurrentProfileData, PutUserCurrentProfileErrors, PutUserCurrentProfileResponses, 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, 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, DeleteUserCurrentAvatarData, DeleteUserCurrentAvatarErrors, DeleteUserCurrentAvatarResponses, 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, GetDataTypeBatchData, GetDataTypeBatchErrors, GetDataTypeBatchResponses, GetDataTypeByIdData, GetDataTypeByIdErrors, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedErrors, GetDataTypeByIdIsUsedResponses, GetDataTypeByIdReferencedByData, GetDataTypeByIdReferencedByErrors, GetDataTypeByIdReferencedByResponses, GetDataTypeByIdResponses, GetDataTypeByIdSchemaData, GetDataTypeByIdSchemaErrors, GetDataTypeByIdSchemaResponses, GetDataTypeConfigurationData, GetDataTypeConfigurationErrors, GetDataTypeConfigurationResponses, GetDataTypeFolderByIdData, GetDataTypeFolderByIdErrors, GetDataTypeFolderByIdResponses, GetDataTypeSchemasBatchData, GetDataTypeSchemasBatchErrors, GetDataTypeSchemasBatchResponses, GetDictionaryByIdData, GetDictionaryByIdErrors, GetDictionaryByIdExportData, GetDictionaryByIdExportErrors, GetDictionaryByIdExportResponses, GetDictionaryByIdResponses, GetDictionaryData, GetDictionaryErrors, GetDictionaryResponses, GetDocumentAreReferencedData, GetDocumentAreReferencedErrors, GetDocumentAreReferencedResponses, GetDocumentBlueprintByIdAuditLogData, GetDocumentBlueprintByIdAuditLogErrors, GetDocumentBlueprintByIdAuditLogResponses, 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, GetDocumentTypeBatchData, GetDocumentTypeBatchErrors, GetDocumentTypeBatchResponses, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenErrors, GetDocumentTypeByIdAllowedChildrenResponses, GetDocumentTypeByIdAllowedParentsData, GetDocumentTypeByIdAllowedParentsErrors, GetDocumentTypeByIdAllowedParentsResponses, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintErrors, GetDocumentTypeByIdBlueprintResponses, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesErrors, GetDocumentTypeByIdCompositionReferencesResponses, GetDocumentTypeByIdData, GetDocumentTypeByIdErrors, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportErrors, GetDocumentTypeByIdExportResponses, GetDocumentTypeByIdResponses, GetDocumentTypeByIdSchemaData, GetDocumentTypeByIdSchemaErrors, GetDocumentTypeByIdSchemaResponses, 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, GetItemDataTypeAncestorsData, GetItemDataTypeAncestorsErrors, GetItemDataTypeAncestorsResponses, GetItemDataTypeData, GetItemDataTypeErrors, GetItemDataTypeResponses, GetItemDataTypeSearchData, GetItemDataTypeSearchErrors, GetItemDataTypeSearchResponses, GetItemDictionaryData, GetItemDictionaryErrors, GetItemDictionaryResponses, GetItemDocumentAncestorsData, GetItemDocumentAncestorsErrors, GetItemDocumentAncestorsResponses, GetItemDocumentBlueprintData, GetItemDocumentBlueprintErrors, GetItemDocumentBlueprintResponses, GetItemDocumentData, GetItemDocumentErrors, GetItemDocumentResponses, GetItemDocumentSearchData, GetItemDocumentSearchErrors, GetItemDocumentSearchResponses, GetItemDocumentTypeAncestorsData, GetItemDocumentTypeAncestorsErrors, GetItemDocumentTypeAncestorsResponses, GetItemDocumentTypeData, GetItemDocumentTypeErrors, GetItemDocumentTypeResponses, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchErrors, GetItemDocumentTypeSearchResponses, GetItemLanguageData, GetItemLanguageDefaultData, GetItemLanguageDefaultErrors, GetItemLanguageDefaultResponses, GetItemLanguageErrors, GetItemLanguageResponses, GetItemMediaAncestorsData, GetItemMediaAncestorsErrors, GetItemMediaAncestorsResponses, GetItemMediaData, GetItemMediaErrors, GetItemMediaResponses, GetItemMediaSearchData, GetItemMediaSearchErrors, GetItemMediaSearchResponses, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedErrors, GetItemMediaTypeAllowedResponses, GetItemMediaTypeAncestorsData, GetItemMediaTypeAncestorsErrors, GetItemMediaTypeAncestorsResponses, GetItemMediaTypeData, GetItemMediaTypeErrors, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersErrors, GetItemMediaTypeFoldersResponses, GetItemMediaTypeResponses, GetItemMediaTypeSearchData, GetItemMediaTypeSearchErrors, GetItemMediaTypeSearchResponses, GetItemMemberAncestorsData, GetItemMemberAncestorsErrors, GetItemMemberAncestorsResponses, GetItemMemberData, GetItemMemberErrors, GetItemMemberGroupData, GetItemMemberGroupErrors, GetItemMemberGroupResponses, GetItemMemberResponses, GetItemMemberSearchData, GetItemMemberSearchErrors, GetItemMemberSearchResponses, GetItemMemberTypeAncestorsData, GetItemMemberTypeAncestorsErrors, GetItemMemberTypeAncestorsResponses, GetItemMemberTypeData, GetItemMemberTypeErrors, GetItemMemberTypeResponses, GetItemMemberTypeSearchData, GetItemMemberTypeSearchErrors, GetItemMemberTypeSearchResponses, GetItemPartialViewData, GetItemPartialViewErrors, GetItemPartialViewResponses, GetItemRelationTypeData, GetItemRelationTypeErrors, GetItemRelationTypeResponses, GetItemScriptData, GetItemScriptErrors, GetItemScriptResponses, GetItemStaticFileData, GetItemStaticFileErrors, GetItemStaticFileResponses, GetItemStylesheetData, GetItemStylesheetErrors, GetItemStylesheetResponses, GetItemTemplateAncestorsData, GetItemTemplateAncestorsErrors, GetItemTemplateAncestorsResponses, 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, GetMediaTypeBatchData, GetMediaTypeBatchErrors, GetMediaTypeBatchResponses, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenErrors, GetMediaTypeByIdAllowedChildrenResponses, GetMediaTypeByIdAllowedParentsData, GetMediaTypeByIdAllowedParentsErrors, GetMediaTypeByIdAllowedParentsResponses, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesErrors, GetMediaTypeByIdCompositionReferencesResponses, GetMediaTypeByIdData, GetMediaTypeByIdErrors, GetMediaTypeByIdExportData, GetMediaTypeByIdExportErrors, GetMediaTypeByIdExportResponses, GetMediaTypeByIdResponses, GetMediaTypeByIdSchemaData, GetMediaTypeByIdSchemaErrors, GetMediaTypeByIdSchemaResponses, 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, GetMemberTypeAllowedAtRootData, GetMemberTypeAllowedAtRootErrors, GetMemberTypeAllowedAtRootResponses, GetMemberTypeBatchData, GetMemberTypeBatchErrors, GetMemberTypeBatchResponses, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesErrors, GetMemberTypeByIdCompositionReferencesResponses, GetMemberTypeByIdData, GetMemberTypeByIdErrors, GetMemberTypeByIdExportData, GetMemberTypeByIdExportErrors, GetMemberTypeByIdExportResponses, GetMemberTypeByIdResponses, GetMemberTypeByIdSchemaData, GetMemberTypeByIdSchemaErrors, GetMemberTypeByIdSchemaResponses, 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, GetTreeDataTypeSearchData, GetTreeDataTypeSearchErrors, GetTreeDataTypeSearchResponses, 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, GetTreeDocumentTypeSearchData, GetTreeDocumentTypeSearchErrors, GetTreeDocumentTypeSearchResponses, 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, PatchDocumentByIdPatchData, PatchDocumentByIdPatchErrors, PatchDocumentByIdPatchResponses, 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, PostDocumentCreateAndPublishData, PostDocumentCreateAndPublishErrors, PostDocumentCreateAndPublishResponses, 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, PutDocumentByIdUpdateAndPublishData, PutDocumentByIdUpdateAndPublishErrors, PutDocumentByIdUpdateAndPublishResponses, 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, PutUserCurrentProfileData, PutUserCurrentProfileErrors, PutUserCurrentProfileResponses, PutUserDataData, PutUserDataErrors, PutUserDataResponses, PutUserGroupByIdData, PutUserGroupByIdErrors, PutUserGroupByIdResponses, PutWebhookByIdData, PutWebhookByIdErrors, PutWebhookByIdResponses } from './types.gen'; export type Options = Options2 & { /** @@ -2292,6 +2292,28 @@ export class DocumentService { }); } + /** + * Updates and publishes a document. + * + * Updates and publishes a document identified by the provided Id with the details from the request model. + */ + public static putDocumentByIdUpdateAndPublish(options: Options) { + return (options.client ?? client).put({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/document/{id}/update-and-publish', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + /** * Gets a collection of items that reference documents. * @@ -2328,6 +2350,28 @@ export class DocumentService { }); } + /** + * Creates and publishes a new document. + * + * Creates and publishes a new document with the configuration specified in the request model. + */ + public static postDocumentCreateAndPublish(options?: Options) { + return (options?.client ?? client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/document/create-and-publish', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + /** * Sorts documents. * 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 9f2f8ed2b0c0..3de4fde7fc27 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 @@ -161,6 +161,16 @@ export type CopyMemberTypeRequestModel = { target?: ReferenceByIdModel | null; }; +export type CreateAndPublishDocumentRequestModel = { + values: Array; + variants: Array; + id?: string | null; + parent?: ReferenceByIdModel | null; + documentType: ReferenceByIdModel; + template: ReferenceByIdModel | null; + culturesToPublish: Array; +}; + export type CreateDataTypeRequestModel = { name: string; editorAlias: string; @@ -2734,6 +2744,13 @@ export type UnpublishDocumentRequestModel = { cultures?: Array | null; }; +export type UpdateAndPublishDocumentRequestModel = { + values: Array; + variants: Array; + template?: ReferenceByIdModel | null; + culturesToPublish: Array; +}; + export type UpdateCurrentUserRequestModel = { languageIsoCode: string; }; @@ -7320,6 +7337,43 @@ export type PutDocumentByIdUnpublishResponses = { 200: unknown; }; +export type PutDocumentByIdUpdateAndPublishData = { + body?: UpdateAndPublishDocumentRequestModel; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/document/{id}/update-and-publish'; +}; + +export type PutDocumentByIdUpdateAndPublishErrors = { + /** + * 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 PutDocumentByIdUpdateAndPublishError = PutDocumentByIdUpdateAndPublishErrors[keyof PutDocumentByIdUpdateAndPublishErrors]; + +export type PutDocumentByIdUpdateAndPublishResponses = { + /** + * OK + */ + 200: unknown; +}; + export type PutUmbracoManagementApiV11DocumentByIdValidate11Data = { body?: ValidateUpdateDocumentRequestModel; path: { @@ -7415,6 +7469,41 @@ export type GetDocumentConfigurationResponses = { export type GetDocumentConfigurationResponse = GetDocumentConfigurationResponses[keyof GetDocumentConfigurationResponses]; +export type PostDocumentCreateAndPublishData = { + body?: CreateAndPublishDocumentRequestModel; + path?: never; + query?: never; + url: '/umbraco/management/api/v1/document/create-and-publish'; +}; + +export type PostDocumentCreateAndPublishErrors = { + /** + * 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 PostDocumentCreateAndPublishError = PostDocumentCreateAndPublishErrors[keyof PostDocumentCreateAndPublishErrors]; + +export type PostDocumentCreateAndPublishResponses = { + /** + * Created + */ + 201: unknown; +}; + export type PutDocumentSortData = { body?: SortingRequestModel; path?: never; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/url-pattern-to-string.function.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/url-pattern-to-string.function.test.ts new file mode 100644 index 000000000000..cc4745ebe8e6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/url-pattern-to-string.function.test.ts @@ -0,0 +1,61 @@ +import { umbUrlPatternToString } from './url-pattern-to-string.function.js'; +import { expect } from '@open-wc/testing'; + +describe('umbUrlPatternToString', () => { + describe('when params is null', () => { + it('should return the pattern unchanged', () => { + expect(umbUrlPatternToString('/section/:id/edit', null)).to.eq('/section/:id/edit'); + }); + + it('should return a pattern without parameters unchanged', () => { + expect(umbUrlPatternToString('/section/dashboard', null)).to.eq('/section/dashboard'); + }); + }); + + describe('parameter replacement', () => { + it('should replace a single parameter with its value', () => { + expect(umbUrlPatternToString('/section/:id', { id: '123' })).to.eq('/section/123'); + }); + + it('should replace multiple parameters with their values', () => { + expect(umbUrlPatternToString('/:section/:id/edit', { section: 'content', id: '123' })).to.eq( + '/content/123/edit', + ); + }); + + it('should leave the pattern unchanged when it has no parameters', () => { + expect(umbUrlPatternToString('/section/dashboard', { id: '123' })).to.eq('/section/dashboard'); + }); + + it('should replace a parameter that spans the rest of a path segment', () => { + expect(umbUrlPatternToString('/section/:entityType', { entityType: 'document-type' })).to.eq( + '/section/document-type', + ); + }); + }); + + describe('value coercion', () => { + it('should coerce a number value to a string', () => { + expect(umbUrlPatternToString('/page/:index', { index: 42 })).to.eq('/page/42'); + }); + + it('should call toString on an object value', () => { + const value = { toString: () => 'custom' }; + expect(umbUrlPatternToString('/section/:id', { id: value })).to.eq('/section/custom'); + }); + + it('should render a null value as the string "null"', () => { + expect(umbUrlPatternToString('/section/:id', { id: null })).to.eq('/section/null'); + }); + }); + + describe('missing parameters', () => { + it('should keep the literal token when the value is undefined', () => { + expect(umbUrlPatternToString('/section/:id', {})).to.eq('/section/:id'); + }); + + it('should replace known parameters and keep unknown ones literal', () => { + expect(umbUrlPatternToString('/:section/:id', { section: 'content' })).to.eq('/content/:id'); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/url-pattern-to-string.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/url-pattern-to-string.function.ts index e7175e73082b..b36ab9149518 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/url-pattern-to-string.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/url-pattern-to-string.function.ts @@ -8,9 +8,12 @@ export type UrlParametersRecord = UmbUrlParametersRecord; const PARAM_IDENTIFIER = /:([^/]+)/g; /** - * - * @param pattern - * @param params + * Resolves the `:parameter` tokens in a URL pattern using the given parameter values. + * @param {string} pattern - A URL pattern where parameters are written as `:name` (e.g. `/section/:id/edit`). + * @param {UmbUrlParametersRecord | null} params - Key value object, where keys are parameter names and values are the values to replace them with. + * @returns {string} The pattern with its parameter tokens replaced. + * @example + * umbUrlPatternToString('/section/:id/edit', { id: 123 }); // '/section/123/edit' */ export function umbUrlPatternToString(pattern: string, params: UmbUrlParametersRecord | null): string { return params diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-route-manager.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-route-manager.controller.ts index 6aa5332d926e..7caf10f17f0d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-route-manager.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-route-manager.controller.ts @@ -1,5 +1,5 @@ import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; -import { UmbArrayState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; import type { IComponentRoute, UmbRoute } from '@umbraco-cms/backoffice/router'; /** @@ -12,7 +12,8 @@ export class UmbWorkspaceRouteManager extends UmbControllerBase { #routes = new UmbArrayState([], (x) => x.path); public readonly routes = this.#routes.asObservable(); - #activeLocalPath = new UmbStringState(''); + #absolutePath?: string; + #localPath?: string; /** * Set the routes for the workspace. @@ -34,7 +35,10 @@ export class UmbWorkspaceRouteManager extends UmbControllerBase { const oldSetupCallback = route.setup; route.setup = (_component: any, info: any) => { - this.#activeLocalPath.setValue(info.match.fragments.consumed); + // TODO: could this be invalidated by the time it's used? [NL] + // That would be a parent router switches path without the view being changed... + this.#absolutePath = info.slot?.constructAbsolutePath(); + this.#localPath = info.match.fragments.consumed; if (oldSetupCallback) { oldSetupCallback(_component, info); @@ -58,10 +62,19 @@ export class UmbWorkspaceRouteManager extends UmbControllerBase { /** * Get the active local path. - * @returns {*} {string} + * @returns {string} The active local path. * @memberof UmbWorkspaceRouteManager */ getActiveLocalPath(): string { - return this.#activeLocalPath.getValue(); + return this.#localPath ?? ''; + } + + /** + * Get the absolute path. + * @returns {string | undefined} The absolute path. + * @memberof UmbWorkspaceRouteManager + */ + getAbsolutePath(): string | undefined { + return this.#absolutePath; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts index 0499e6769d22..dd78848286f5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts @@ -1,4 +1,5 @@ import { UmbSubmittableWorkspaceContextBase } from '../submittable/index.js'; +import { umbWorkspaceWillNavigateAway } from '../utils/check-will-navigate-away.function.js'; import { UmbEntityWorkspaceDataManager } from '../entity/entity-workspace-data-manager.js'; import type { UmbSubmittableTreeEntityWorkspaceContext } from '../contexts/tokens/index.js'; import type { UmbEntityDetailWorkspaceContextArgs, UmbEntityDetailWorkspaceContextCreateArgs } from './types.js'; @@ -28,11 +29,11 @@ const LOADING_STATE_UNIQUE = 'umbLoadingEntityDetail'; const FORBIDDEN_STATE_UNIQUE = 'umbForbiddenEntityDetail'; export abstract class UmbEntityDetailWorkspaceContextBase< - DetailModelType extends UmbEntityModel = UmbEntityModel, - DetailRepositoryType extends UmbDetailRepository = UmbDetailRepository, - CreateArgsType extends - UmbEntityDetailWorkspaceContextCreateArgs = UmbEntityDetailWorkspaceContextCreateArgs, - > + DetailModelType extends UmbEntityModel = UmbEntityModel, + DetailRepositoryType extends UmbDetailRepository = UmbDetailRepository, + CreateArgsType extends UmbEntityDetailWorkspaceContextCreateArgs = + UmbEntityDetailWorkspaceContextCreateArgs, +> extends UmbSubmittableWorkspaceContextBase implements UmbSubmittableTreeEntityWorkspaceContext { @@ -291,6 +292,19 @@ export abstract class UmbEntityDetailWorkspaceContextBase< } } + /** + * Requests the latest persisted version of the entity from the server WITHOUT applying it to the + * workspace state, and returns the processed data. + * @returns { Promise } The latest persisted data. + */ + public async loadWithoutPersist(): Promise { + const unique = this.getUnique(); + if (!unique) throw new Error('Unique is not set'); + const { data, error } = await this._detailRepository!.requestByUnique(unique); + if (error || !data) throw new Error('Error loading entity', { cause: error }); + return await this._processIncomingData(data); + } + /** * Method to check if the workspace data is loaded. * @returns { Promise | undefined } true if the workspace data is loaded. @@ -391,10 +405,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase< * @memberof UmbEntityWorkspaceContextBase */ protected _checkWillNavigateAway(newUrl: string | URL): boolean { - if (newUrl instanceof URL) { - newUrl = newUrl.href; - } - return !newUrl.includes(this.routes.getActiveLocalPath()); + return umbWorkspaceWillNavigateAway(this.routes, this.getUnique(), newUrl); } protected async _create(currentData: DetailModelType, parent: UmbEntityModel) { @@ -402,7 +413,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase< const { error, data } = await this._detailRepository.create(currentData, parent.unique); if (error || !data) { - throw error?.message ?? 'Repository did not return data after create.'; + throw new Error('Repository did not return data after create.', { cause: error }); } this.#entityContext.setUnique(data.unique); @@ -431,7 +442,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase< protected async _update(currentData: DetailModelType) { const { error, data } = await this._detailRepository!.save(currentData); if (error || !data) { - throw error?.message ?? 'Repository did not return data after create.'; + throw new Error('Entity Detail Repository failed saving', { cause: error }); } this._data.setPersisted(data); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/index.ts index 3ee9f937020e..783e90aa5ef7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/index.ts @@ -8,6 +8,7 @@ export * from './info-app/index.js'; export * from './modals/index.js'; export * from './paths.js'; export * from './submittable/index.js'; +export * from './utils/check-will-navigate-away.function.js'; export * from './utils/object-to-property-value-array.function.js'; export * from './workspace-property-dataset/index.js'; export * from './workspace.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/utils/check-will-navigate-away.function.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/utils/check-will-navigate-away.function.test.ts new file mode 100644 index 000000000000..575ea7dc1a88 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/utils/check-will-navigate-away.function.test.ts @@ -0,0 +1,73 @@ +import { umbWorkspaceWillNavigateAway } from './check-will-navigate-away.function.js'; +import type { UmbWorkspaceRouteManager } from '../controllers/workspace-route-manager.controller.js'; +import type { UmbRoute } from '@umbraco-cms/backoffice/router'; +import { expect } from '@open-wc/testing'; + +const BASE = '/umbraco/section/content/workspace/document'; + +function routeManager(config: { + absolutePath?: string; + activeLocalPath?: string; + routes?: Array>; +}): UmbWorkspaceRouteManager { + return { + getAbsolutePath: () => config.absolutePath, + getActiveLocalPath: () => config.activeLocalPath ?? '', + getRoutes: () => (config.routes ?? []) as Array, + } as unknown as UmbWorkspaceRouteManager; +} + +describe('umbWorkspaceWillNavigateAway', () => { + it('does not block before the workspace has been routed (no absolute path)', () => { + const routes = routeManager({ absolutePath: undefined, activeLocalPath: 'edit/123' }); + expect(umbWorkspaceWillNavigateAway(routes, '123', `${BASE}/edit/123`)).to.be.false; + }); + + it('stays when navigating to a sub-view of the active route', () => { + const routes = routeManager({ absolutePath: BASE, activeLocalPath: 'edit/123' }); + expect(umbWorkspaceWillNavigateAway(routes, '123', `${BASE}/edit/123/view/content`)).to.be.false; + }); + + it('stays when the target is exactly the active route', () => { + const routes = routeManager({ absolutePath: BASE, activeLocalPath: 'edit/123' }); + expect(umbWorkspaceWillNavigateAway(routes, '123', `${BASE}/edit/123`)).to.be.false; + }); + + it('navigates away when an identical segment appears elsewhere in the URL (positional anchoring)', () => { + // The bug: a duplicate `edit/123` deeper in the path must not mask a real change to the owned segment. + const routes = routeManager({ + absolutePath: BASE, + activeLocalPath: 'edit/123', + routes: [{ path: 'edit/:unique' }], + }); + expect(umbWorkspaceWillNavigateAway(routes, '123', `${BASE}/edit/456/something/edit/123`)).to.be.true; + }); + + it('does not match a unique that is only a string prefix of the new segment', () => { + const routes = routeManager({ absolutePath: BASE, activeLocalPath: 'edit/123' }); + expect(umbWorkspaceWillNavigateAway(routes, '123', `${BASE}/edit/1234`)).to.be.true; + }); + + it('stays through the create -> edit redirect for the freshly created entity', () => { + const routes = routeManager({ + absolutePath: BASE, + activeLocalPath: 'create/parent/document-root/null/dt-1', + routes: [{ path: 'create/parent/:parentEntityType/:parentUnique/:documentTypeUnique' }, { path: 'edit/:unique' }], + }); + expect(umbWorkspaceWillNavigateAway(routes, 'new-1', `${BASE}/edit/new-1`)).to.be.false; + }); + + it('navigates away when leaving the workspace mount entirely', () => { + const routes = routeManager({ + absolutePath: BASE, + activeLocalPath: 'edit/123', + routes: [{ path: 'edit/:unique' }], + }); + expect(umbWorkspaceWillNavigateAway(routes, '123', '/umbraco/section/media/workspace/media/edit/123')).to.be.true; + }); + + it('ignores query strings when matching', () => { + const routes = routeManager({ absolutePath: BASE, activeLocalPath: 'edit/123' }); + expect(umbWorkspaceWillNavigateAway(routes, '123', `${BASE}/edit/123?foo=bar`)).to.be.false; + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/utils/check-will-navigate-away.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/utils/check-will-navigate-away.function.ts new file mode 100644 index 000000000000..1a5f35e6ed11 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/utils/check-will-navigate-away.function.ts @@ -0,0 +1,41 @@ +import type { UmbWorkspaceRouteManager } from '../controllers/workspace-route-manager.controller.js'; +import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { umbUrlPatternToString } from '@umbraco-cms/backoffice/utils'; + +/** + * Determines whether a pending navigation leaves the workspace that owns the given routes. + * @param {UmbWorkspaceRouteManager} routeManager The route manager of the workspace. + * @param {UmbEntityUnique | undefined} unique The unique of the entity currently being edited. + * @param {string | URL} newUrl The url the navigation is heading towards. + * @returns {boolean} true if the navigation leaves the workspace. + */ +export function umbWorkspaceWillNavigateAway( + routeManager: UmbWorkspaceRouteManager, + unique: UmbEntityUnique | undefined, + newUrl: string | URL, +): boolean { + const basePath = routeManager.getAbsolutePath(); + // Before the workspace has been routed we cannot position the match, so we never block. + if (basePath === undefined) return false; + + const newPath = (newUrl instanceof URL ? newUrl : new URL(newUrl, window.location.origin)).pathname; + + // Check against the active local path + const sameEntityPaths = [routeManager.getActiveLocalPath()]; + + // Check against all routes of the workspace that carry the same unique. + if (unique) { + for (const route of routeManager.getRoutes()) { + // Routes that don't carry the unique keep their unresolved `:param` tokens, so they + // can never match a concrete URL and are effectively skipped. + sameEntityPaths.push(umbUrlPatternToString(route.path, { unique })); + } + } + + // Run the checks: + return !sameEntityPaths.some((localPath) => { + if (localPath === '') return false; + const absolutePath = `${basePath}/${localPath}`; + return newPath === absolutePath || newPath.startsWith(`${absolutePath}/`); + }); +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.repository.ts index 56582bc8ef8d..562a348c386f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.repository.ts @@ -6,6 +6,38 @@ import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; export class UmbDocumentPublishingRepository extends UmbRepositoryBase { #publishingDataSource = new UmbDocumentPublishingServerDataSource(this); + /** + * Creates and publishes a new Document in a single operation + * @param {UmbDocumentDetailModel} model - The Document to create + * @param {Array} variantIds - The variants to publish after creating + * @param {string | null} parentUnique - The unique of the parent to create under + * @returns {*} + * @memberof UmbDocumentPublishingRepository + */ + async createAndPublish( + model: UmbDocumentDetailModel, + variantIds: Array, + parentUnique: string | null = null, + ) { + if (!model) throw new Error('Document is missing'); + if (!model.unique) throw new Error('Document unique is missing'); + + return this.#publishingDataSource.createAndPublish(model, variantIds, parentUnique); + } + + /** + * Updates and publishes an existing Document in a single operation + * @param {UmbDocumentDetailModel} model - The Document to update + * @param {Array} variantIds - The variants to publish after updating + * @returns {*} + * @memberof UmbDocumentPublishingRepository + */ + async updateAndPublish(model: UmbDocumentDetailModel, variantIds: Array) { + if (!model.unique) throw new Error('Unique is missing'); + + return this.#publishingDataSource.updateAndPublish(model, variantIds); + } + /** * Publish one or more variants of a Document * @param {string} id diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.server.data-source.test.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.server.data-source.test.ts new file mode 100644 index 000000000000..a903e6c5bee7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.server.data-source.test.ts @@ -0,0 +1,98 @@ +import { expect } from '@open-wc/testing'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { useMockSet } from '@umbraco-cms/internal/mock-manager'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbId } from '@umbraco-cms/backoffice/id'; +import { UmbDocumentServerDataSource } from '../../repository/detail/document-detail.server.data-source.js'; +import { UmbDocumentPublishingServerDataSource } from './document-publishing.server.data-source.js'; + +const VARIANT_DOCUMENT_ID = 'variant-documents-variant-document-id'; +const INVARIANT_DOCUMENT_ID = 'variant-documents-invariant-document-id'; + +@customElement('umb-test-document-publishing-data-source-host') +class UmbTestHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbDocumentPublishingServerDataSource (create/update-and-publish)', () => { + let hostElement: UmbTestHostElement; + // The detail data source is used only to read the document back and assert the published outcome, + // since the and-publish endpoints return no document body. + let detailDataSource: UmbDocumentServerDataSource; + let publishingDataSource: UmbDocumentPublishingServerDataSource; + + beforeEach(async () => { + await useMockSet('documents'); + hostElement = new UmbTestHostElement(); + document.body.appendChild(hostElement); + detailDataSource = new UmbDocumentServerDataSource(hostElement); + publishingDataSource = new UmbDocumentPublishingServerDataSource(hostElement); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('createAndPublish', () => { + it('creates a new document and publishes only the requested culture', async () => { + // Use an existing document as a valid model template, with a fresh unique so it is created anew. + const { data: template } = await detailDataSource.read(VARIANT_DOCUMENT_ID); + expect(template, 'precondition: template document loads').to.exist; + const newId = UmbId.new(); + const newModel = { ...template!, unique: newId }; + + const da = UmbVariantId.Create({ culture: 'da', segment: null }); + const { error } = await publishingDataSource.createAndPublish(newModel, [da], null); + expect(error).to.be.undefined; + + const { data: created } = await detailDataSource.read(newId); + const daVariant = created!.variants.find((v) => v.culture === 'da'); + expect(daVariant?.state, 'the requested culture (da) is Published').to.equal('Published'); + }); + }); + + describe('updateAndPublish', () => { + it('publishes only the requested culture', async () => { + const { data: model } = await detailDataSource.read(VARIANT_DOCUMENT_ID); + expect(model, 'precondition: document loads').to.exist; + + // da starts as Draft; publishing only da should leave en-US untouched. + const daVariantId = UmbVariantId.Create({ culture: 'da', segment: null }); + const { error } = await publishingDataSource.updateAndPublish(model!, [daVariantId]); + expect(error).to.be.undefined; + + const { data: updated } = await detailDataSource.read(VARIANT_DOCUMENT_ID); + const da = updated!.variants.find((v) => v.culture === 'da'); + const enUs = updated!.variants.find((v) => v.culture === 'en-US'); + expect(da?.state, 'da becomes Published').to.equal('Published'); + expect(enUs?.state, 'en-US is unaffected (was already Published)').to.equal('Published'); + }); + + it('does not publish a culture that was not requested', async () => { + // A fresh mock set where da is Draft. + const { data: model } = await detailDataSource.read(VARIANT_DOCUMENT_ID); + const enUs = UmbVariantId.Create({ culture: 'en-US', segment: null }); + + const { error } = await publishingDataSource.updateAndPublish(model!, [enUs]); + expect(error).to.be.undefined; + + const { data: updated } = await detailDataSource.read(VARIANT_DOCUMENT_ID); + const da = updated!.variants.find((v) => v.culture === 'da'); + expect(da?.state, 'da stays Draft when only en-US is published').to.equal('Draft'); + }); + }); + + describe('invariant update-and-publish', () => { + it('publishes the invariant variant using an empty culturesToPublish array', async () => { + const { data: model } = await detailDataSource.read(INVARIANT_DOCUMENT_ID); + expect(model, 'precondition: invariant document loads').to.exist; + + const invariant = UmbVariantId.CreateInvariant(); + const { error } = await publishingDataSource.updateAndPublish(model!, [invariant]); + expect(error).to.be.undefined; + + const { data: updated } = await detailDataSource.read(INVARIANT_DOCUMENT_ID); + const variant = updated!.variants.find((v) => v.culture === null); + expect(variant?.state, 'the invariant variant is Published').to.equal('Published'); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.server.data-source.ts index 41024e024c38..67b063a2b1ad 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.server.data-source.ts @@ -1,10 +1,16 @@ import type { UmbDocumentDetailModel, UmbDocumentVariantPublishModel } from '../../types.js'; import { UMB_DOCUMENT_ENTITY_TYPE } from '../../entity.js'; +import { + umbMapDocumentCreateRequestBody, + umbMapDocumentUpdateRequestBody, +} from '../../repository/detail/document-detail-request.mappers.js'; import type { + CreateAndPublishDocumentRequestModel, CultureAndScheduleRequestModel, PublishDocumentRequestModel, PublishDocumentWithDescendantsRequestModel, UnpublishDocumentRequestModel, + UpdateAndPublishDocumentRequestModel, } from '@umbraco-cms/backoffice/external/backend-api'; import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; @@ -29,6 +35,67 @@ export class UmbDocumentPublishingServerDataSource { this.#host = host; } + /** + * Creates and publishes a new Document on the server in a single operation + * @param {UmbDocumentDetailModel} model - Document Model + * @param {Array} variantIds - The variants to publish after creating + * @param {string | null} parentUnique - The unique of the parent to create under + * @returns {*} + * @memberof UmbDocumentPublishingServerDataSource + */ + async createAndPublish( + model: UmbDocumentDetailModel, + variantIds: Array, + parentUnique: string | null = null, + ) { + if (!model) throw new Error('Document is missing'); + if (!model.unique) throw new Error('Document unique is missing'); + + const body: CreateAndPublishDocumentRequestModel = { + ...umbMapDocumentCreateRequestBody(model, parentUnique), + culturesToPublish: this.#mapCulturesToPublish(variantIds), + }; + + // 201 Created returns only the key (no document body). The workspace reloads after this to refresh + // its state, so we deliberately do NOT re-read the full document here — that would be a redundant + // round-trip on top of the reload. + return tryExecute(this.#host, DocumentService.postDocumentCreateAndPublish({ body })); + } + + /** + * Updates and publishes a Document on the server in a single operation + * @param {UmbDocumentDetailModel} model - Document Model + * @param {Array} variantIds - The variants to publish after updating + * @returns {*} + * @memberof UmbDocumentPublishingServerDataSource + */ + async updateAndPublish(model: UmbDocumentDetailModel, variantIds: Array) { + if (!model.unique) throw new Error('Unique is missing'); + + const body: UpdateAndPublishDocumentRequestModel = { + ...umbMapDocumentUpdateRequestBody(model), + culturesToPublish: this.#mapCulturesToPublish(variantIds), + }; + + // 200 returns only a notification header (no document body). The workspace reloads after this to + // refresh its state, so we deliberately do NOT re-read the full document here. + return tryExecute( + this.#host, + DocumentService.putDocumentByIdUpdateAndPublish({ path: { id: model.unique }, body }), + ); + } + + /** + * Maps the selected variants to the culture codes accepted by the create/update-and-publish endpoints. + * Invariant content types require an empty array (cultures cannot be specified), and the server rejects + * `null`/`"*"` entries, so invariant variants are filtered out and only distinct culture codes remain. + * @param {Array} variantIds - The selected variants to publish + * @returns {Array} The distinct culture codes to publish + */ + #mapCulturesToPublish(variantIds: Array): Array { + return [...new Set(variantIds.filter((x) => !x.isCultureInvariant()).map((x) => x.toCultureString()))]; + } + /** * Publish one or more variants of a Document * @param {string} unique diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts index 1147abdd20c8..268ebcbb1e0d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts @@ -170,29 +170,41 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase implem return this.#documentWorkspaceContext.validateVariantsAndSubmit( variantIds, async () => { - if (!this.#documentWorkspaceContext) { - throw new Error('Document workspace context is missing'); - } - - // Save the document before scheduling - await this.#documentWorkspaceContext.performCreateOrUpdate(variantIds, saveData); - - // Schedule the document - const { error } = await this.#publishingRepository.publish(unique, variants); - if (error) { + try { + if (!this.#documentWorkspaceContext) { + throw new Error('Document workspace context is missing'); + } + + // Save the document before scheduling + await this.#documentWorkspaceContext.performCreateOrUpdate(variantIds, saveData); + + // Schedule the document + const { error } = await this.#publishingRepository.publish(unique, variants); + if (error) { + throw error; + } + + const notification = { + data: { message: this.#localize.term('speechBubbles_editContentScheduledSavedText') }, + }; + this.#notificationContext?.peek('positive', notification); + + // reload the document so all states are updated after the publish operation + // TODO: It seems wrong to make a full reload, In this case I think we can just update the variants status? [NL] + await this.#documentWorkspaceContext.reload(); + this.#loadAndProcessLastPublished(); + + // request reload of this entity + const structureEvent = new UmbRequestReloadStructureForEntityEvent({ entityType, unique }); + this.#eventContext?.dispatchEvent(structureEvent); + } catch (error) { + // Notify only on the publish path. The validation-failure path below already + // notifies, so a shared top-level .catch would fire a second toast. [JOV] + this.#notificationContext?.peek('danger', { + data: { message: this.#localize.term('speechBubbles_editContentScheduledNotSavedText') }, + }); return Promise.reject(error); } - - const notification = { data: { message: this.#localize.term('speechBubbles_editContentScheduledSavedText') } }; - this.#notificationContext?.peek('positive', notification); - - // reload the document so all states are updated after the publish operation - await this.#documentWorkspaceContext.reload(); - this.#loadAndProcessLastPublished(); - - // request reload of this entity - const structureEvent = new UmbRequestReloadStructureForEntityEvent({ entityType, unique }); - this.#eventContext?.dispatchEvent(structureEvent); }, async (reason?: any) => { const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); @@ -290,6 +302,7 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase implem }); // reload the document so all states are updated after the publish operation + // TODO: It seems wrong to make a full reload, I think we should only load the selected variants. [NL] await this.#documentWorkspaceContext.reload(); await this.#loadAndProcessLastPublished(); @@ -322,6 +335,7 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase implem await new UmbUnpublishDocumentEntityAction(this, { unique, entityType, meta: {} as never }).execute(); // Reload workspace data to reflect the unpublished state + // TODO: It seems wrong to make a full reload, In this case I think we can just update the variants status? [NL] await this.#documentWorkspaceContext.reload(); await this.#loadAndProcessLastPublished(); } @@ -368,8 +382,15 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase implem return this.#documentWorkspaceContext.validateVariantsAndSubmit( variantIds, - async () => { - return this.#performSaveAndPublish(variantIds, saveData); + () => { + // Notify only on the publish path. The validation-failure path below already + // notifies, so a shared top-level .catch would fire a second, contradictory toast. [JOV] + return this.#performSaveAndPublish(variantIds, saveData).catch((error) => { + this.#notificationContext?.peek('danger', { + data: { message: this.#localize.term('speechBubbles_editContentPublishedFailed') }, + }); + return Promise.reject(error); + }); }, async (reason?: any) => { // If data of the selection is not valid Then just save: @@ -399,33 +420,49 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase implem const entityType = this.#documentWorkspaceContext.getEntityType(); if (!entityType) throw new Error('Entity type is missing'); - await this.#documentWorkspaceContext.performCreateOrUpdate(variantIds, saveData); + // The publish already succeeded server-side once we reach the read-back; a failed read-back must not be + // reported as a publish failure, so we fall back to the submitted data and flag the editor as stale. + let reloadAfterPublishFailed = false; + const loadAfterPublish = async (): Promise => { + try { + return await this.#documentWorkspaceContext!.loadWithoutPersist(); + } catch { + reloadAfterPublishFailed = true; + return saveData; + } + }; - const { error } = await this.#publishingRepository.publish( - unique, - variantIds.map((variantId) => ({ variantId })), - ); + await this.#documentWorkspaceContext.performCreateOrUpdate(variantIds, saveData, { + create: async (data, ids, parent) => { + const { error } = await this.#publishingRepository.createAndPublish(data, ids, parent.unique); + if (error) throw new Error('Error creating and publishing document', { cause: error }); + return loadAfterPublish(); + }, + update: async (data, ids) => { + const { error } = await this.#publishingRepository.updateAndPublish(data, ids); + if (error) throw new Error('Error updating and publishing document', { cause: error }); + return loadAfterPublish(); + }, + }); - if (!error) { - this.#notificationContext?.peek('positive', { + this.#notificationContext?.peek('positive', { + data: { + message: this.#localize.term('speechBubbles_editContentPublishedHeader'), + }, + }); + + if (reloadAfterPublishFailed) { + this.#notificationContext?.peek('warning', { data: { - message: this.#localize.term('speechBubbles_editContentPublishedHeader'), + message: this.#localize.term('speechBubbles_editContentPublishedReloadFailed'), }, }); + } - // Clear stale published data and pending changes state so the - // persistedData observer does not run a comparison against outdated - // data during reload, which would briefly show a false-positive - // "pending changes" state. - this.#clear(); - - // reload the document so all states are updated after the publish operation - await this.#documentWorkspaceContext.reload(); - await this.#loadAndProcessLastPublished(); + await this.#loadAndProcessLastPublished(); - const event = new UmbRequestReloadStructureForEntityEvent({ unique, entityType }); - this.#eventContext?.dispatchEvent(event); - } + const event = new UmbRequestReloadStructureForEntityEvent({ unique, entityType }); + this.#eventContext?.dispatchEvent(event); } #publishableVariantsFilter = (option: UmbDocumentVariantOptionModel) => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail-request.mappers.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail-request.mappers.ts new file mode 100644 index 000000000000..864473b33eb4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail-request.mappers.ts @@ -0,0 +1,40 @@ +import type { UmbDocumentDetailModel } from '../../types.js'; +import type { + CreateDocumentRequestModel, + UpdateDocumentRequestModel, +} from '@umbraco-cms/backoffice/external/backend-api'; + +/** + * Maps a Document detail model to the create request body. + * Shared by the detail create endpoint and the publishing create-and-publish endpoint. + * @param {UmbDocumentDetailModel} model - The Document to create + * @param {string | null} parentUnique - The unique of the parent to create under + * @returns {CreateDocumentRequestModel} The create request body + */ +export function umbMapDocumentCreateRequestBody( + model: UmbDocumentDetailModel, + parentUnique: string | null, +): CreateDocumentRequestModel { + return { + id: model.unique, + parent: parentUnique ? { id: parentUnique } : null, + documentType: { id: model.documentType.unique }, + template: model.template ? { id: model.template.unique } : null, + values: model.values, + variants: model.variants, + }; +} + +/** + * Maps a Document detail model to the update request body. + * Shared by the detail update endpoint and the publishing update-and-publish endpoint. + * @param {UmbDocumentDetailModel} model - The Document to update + * @returns {UpdateDocumentRequestModel} The update request body + */ +export function umbMapDocumentUpdateRequestBody(model: UmbDocumentDetailModel): UpdateDocumentRequestModel { + return { + template: model.template ? { id: model.template.unique } : null, + values: model.values, + variants: model.variants, + }; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.repository.ts index e02feda28f64..3c9c26d957f4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.repository.ts @@ -3,7 +3,10 @@ import { UmbDocumentServerDataSource } from './document-detail.server.data-sourc import { UMB_DOCUMENT_DETAIL_STORE_CONTEXT } from './document-detail.store.context-token.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbDetailRepositoryBase } from '@umbraco-cms/backoffice/repository'; -export class UmbDocumentDetailRepository extends UmbDetailRepositoryBase { +export class UmbDocumentDetailRepository extends UmbDetailRepositoryBase< + UmbDocumentDetailModel, + UmbDocumentServerDataSource +> { constructor(host: UmbControllerHost) { super(host, UmbDocumentServerDataSource, UMB_DOCUMENT_DETAIL_STORE_CONTEXT); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts index 21c278fbcd67..b8153489ef6b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts @@ -11,6 +11,7 @@ import { tryExecute } from '@umbraco-cms/backoffice/resources'; import { umbDeepMerge, type UmbDeepPartialObject } from '@umbraco-cms/backoffice/utils'; import { UmbDocumentTypeDetailServerDataSource } from '@umbraco-cms/backoffice/document-type'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { umbMapDocumentCreateRequestBody, umbMapDocumentUpdateRequestBody } from './document-detail-request.mappers.js'; /** * A data source for the Document that fetches data from the server @@ -125,15 +126,7 @@ export class UmbDocumentServerDataSource 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: CreateDocumentRequestModel = { - id: model.unique, - parent: parentUnique ? { id: parentUnique } : null, - documentType: { id: model.documentType.unique }, - template: model.template ? { id: model.template.unique } : null, - values: model.values, - variants: model.variants, - }; + const body: CreateDocumentRequestModel = umbMapDocumentCreateRequestBody(model, parentUnique); const { data, error } = await tryExecute( this, @@ -158,12 +151,7 @@ export class UmbDocumentServerDataSource async update(model: UmbDocumentDetailModel) { if (!model.unique) throw new Error('Unique is missing'); - // TODO: make data mapper to prevent errors - const body: UpdateDocumentRequestModel = { - template: model.template ? { id: model.template.unique } : null, - values: model.values, - variants: model.variants, - }; + const body: UpdateDocumentRequestModel = umbMapDocumentUpdateRequestBody(model); const { error } = await tryExecute( this, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-create-and-publish.context.test.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-create-and-publish.context.test.ts new file mode 100644 index 000000000000..a24018ed73e0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-create-and-publish.context.test.ts @@ -0,0 +1,110 @@ +import { expect } from '@open-wc/testing'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { useMockSet } from '@umbraco-cms/internal/mock-manager'; +import { UmbDocumentWorkspaceContext } from './document-workspace.context.js'; +import { TEST_MANIFESTS, UmbTestDocumentWorkspaceHostElement } from './document-workspace-context.test-utils.js'; +import { UmbDocumentPublishingServerDataSource } from '../../publishing/repository/document-publishing.server.data-source.js'; + +const INVARIANT_DOCUMENT_TYPE_ID = 'variant-documents-invariant-document-type-id'; +const VARIANT_DOCUMENT_TYPE_ID = 'variant-documents-variant-document-type-id'; +const PARENT_ENTITY = { entityType: 'document', unique: null } as const; +const EN_US = UmbVariantId.Create({ culture: 'en-US', segment: null }); +const DA = UmbVariantId.Create({ culture: 'da', segment: null }); + +/** + * Reproduces the create-and-publish orchestration the publishing workspace context performs: build the + * save data, then drive performCreateOrUpdate with a persist strategy that calls the combined + * create-and-publish endpoint via the publishing data source. The endpoint does not return the saved + * document, so the strategy re-reads the authoritative server state (loadWithoutPersist) and returns it + * for the workspace to merge — published variants take the server values, edited-but-unpublished variants + * stay dirty. + */ +async function createAndPublish( + context: UmbDocumentWorkspaceContext, + publishingDataSource: UmbDocumentPublishingServerDataSource, + variantIds: Array, +) { + const saveData = await context.constructSaveData(variantIds); + await context.performCreateOrUpdate(variantIds, saveData, { + create: async (data, ids, parent) => { + await publishingDataSource.createAndPublish(data, ids, parent.unique); + return context.loadWithoutPersist(); + }, + update: async (data, ids) => { + await publishingDataSource.updateAndPublish(data, ids); + return context.loadWithoutPersist(); + }, + }); +} + +describe('UmbDocumentWorkspaceContext (create-and-publish redirect dirty state)', () => { + let hostElement: UmbTestDocumentWorkspaceHostElement; + let context: UmbDocumentWorkspaceContext; + let publishingDataSource: UmbDocumentPublishingServerDataSource; + + before(() => { + umbExtensionsRegistry.registerMany(TEST_MANIFESTS); + }); + + after(() => { + umbExtensionsRegistry.unregisterMany(TEST_MANIFESTS.map((m) => m.alias)); + }); + + beforeEach(async () => { + await useMockSet('documents'); + hostElement = new UmbTestDocumentWorkspaceHostElement(); + document.body.appendChild(hostElement); + await hostElement.init(); + context = new UmbDocumentWorkspaceContext(hostElement); + publishingDataSource = new UmbDocumentPublishingServerDataSource(hostElement); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + // #68071 follow-up (reported by Andy Butland): after create-and-publish the workspace flips + // isNew=false, which triggers the new->edit redirect ~500ms later. The reload()+transfer that + // reconcile the data state run only after that, so during the redirect window the workspace must + // already report itself as clean — otherwise the redirect navigation pops a spurious + // "Discard unsaved changes" dialog (most visible with an empty RTE, which keeps current != persisted). + it('is not dirty immediately after create-and-publish, before the reload reconciles state', async () => { + await context.create(PARENT_ENTITY, INVARIANT_DOCUMENT_TYPE_ID); + context.setName('New Test Document'); + await context.setPropertyValue('text', 'A value'); + + await createAndPublish(context, publishingDataSource, [UmbVariantId.CreateInvariant()]); + + expect(context.getIsNew(), 'workspace is no longer new').to.be.false; + expect( + context.getHasUnpersistedChanges(), + 'workspace reports no unpersisted changes right after create-and-publish', + ).to.be.false; + }); + + // Guards the #68071 promise on the create path: the reconcile keeps the edited-but-unpublished variant + // dirty. The persist method re-reads the server document; the workspace merges it so the published + // variant takes the (clean) server values while the unpublished Danish edit stays in the current data + // state and remains dirty. This asserts that end state (no data loss). + it('restores an edited-but-unpublished variant after the full create-and-publish flow (no data loss)', async () => { + await context.create(PARENT_ENTITY, VARIANT_DOCUMENT_TYPE_ID); + context.setName('English name', EN_US); + context.setName('Dansk navn', DA); + await context.setPropertyValue('variantText', 'English value', EN_US); + await context.setPropertyValue('variantText', 'Dansk vaerdi', DA); + + await createAndPublish(context, publishingDataSource, [EN_US]); + + const changed = context.getChangedVariants(); + expect( + changed.some((v) => v.culture === 'en-US'), + 'en-US is clean (it was published)', + ).to.be.false; + expect( + changed.some((v) => v.culture === 'da'), + 'da stays dirty (edited but not published)', + ).to.be.true; + expect(context.getPropertyValue('variantText', DA), 'the Danish edit is preserved').to.equal('Dansk vaerdi'); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-save-and-publish.context.test.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-save-and-publish.context.test.ts new file mode 100644 index 000000000000..cfebaa1bab55 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-save-and-publish.context.test.ts @@ -0,0 +1,124 @@ +import { expect } from '@open-wc/testing'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { useMockSet } from '@umbraco-cms/internal/mock-manager'; +import { UmbDocumentWorkspaceContext } from './document-workspace.context.js'; +import { TEST_MANIFESTS, UmbTestDocumentWorkspaceHostElement } from './document-workspace-context.test-utils.js'; +import { UmbDocumentServerDataSource } from '../../repository/detail/document-detail.server.data-source.js'; +import { UmbDocumentPublishingServerDataSource } from '../../publishing/repository/document-publishing.server.data-source.js'; + +const VARIANT_DOCUMENT_ID = 'variant-documents-variant-document-id'; +const EN_US = UmbVariantId.Create({ culture: 'en-US', segment: null }); +const DA = UmbVariantId.Create({ culture: 'da', segment: null }); + +const DA_ORIGINAL = 'Dette er den danske varianttekst.'; + +/** + * Reproduces the save-and-publish orchestration that the publishing workspace context performs + * (#performSaveAndPublish): build the save data, then drive performCreateOrUpdate with a persist strategy + * that performs the combined update-and-publish call. Published variants take the server values, while + * only the selected variants will be updated in draft data, leaving the not selected draft variants unchanged. These scenarios use an existing (non-new) document, so the + * update path is used. + */ +async function saveAndPublish( + context: UmbDocumentWorkspaceContext, + publishingDataSource: UmbDocumentPublishingServerDataSource, + variantIds: Array, +) { + const saveData = await context.constructSaveData(variantIds); + await context.performCreateOrUpdate(variantIds, saveData, { + update: async (data, ids) => { + await publishingDataSource.updateAndPublish(data, ids); + return context.loadWithoutPersist(); + }, + }); +} + +describe('UmbDocumentWorkspaceContext (save & publish data state)', () => { + let hostElement: UmbTestDocumentWorkspaceHostElement; + let context: UmbDocumentWorkspaceContext; + let publishingDataSource: UmbDocumentPublishingServerDataSource; + + before(() => { + umbExtensionsRegistry.registerMany(TEST_MANIFESTS); + }); + + after(() => { + umbExtensionsRegistry.unregisterMany(TEST_MANIFESTS.map((m) => m.alias)); + }); + + beforeEach(async () => { + await useMockSet('documents'); + hostElement = new UmbTestDocumentWorkspaceHostElement(); + document.body.appendChild(hostElement); + await hostElement.init(); + context = new UmbDocumentWorkspaceContext(hostElement); + publishingDataSource = new UmbDocumentPublishingServerDataSource(hostElement); + await context.load(VARIANT_DOCUMENT_ID); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('keeps an unpublished but edited variant dirty after publishing another variant (#68071)', async () => { + await context.setPropertyValue('variantText', 'Edited English', EN_US); + await context.setPropertyValue('variantText', 'Redigeret dansk', DA); + + await saveAndPublish(context, publishingDataSource, [EN_US]); + + // Danish was edited but not published, so it must remain dirty in the current data state. + expect(context.getPropertyValue('variantText', DA)).to.equal('Redigeret dansk'); + const changed = context.getChangedVariants(); + expect( + changed.some((v) => v.culture === 'da'), + 'da is still reported as a changed variant', + ).to.be.true; + + // English was published, so it should now be clean (current matches persisted). + expect(context.getPropertyValue('variantText', EN_US)).to.equal('Edited English'); + expect( + changed.some((v) => v.culture === 'en-US'), + 'en-US is no longer a changed variant', + ).to.be.false; + }); + + it('persists only the published variant on the server', async () => { + await context.setPropertyValue('variantText', 'Edited English', EN_US); + await context.setPropertyValue('variantText', 'Redigeret dansk', DA); + + await saveAndPublish(context, publishingDataSource, [EN_US]); + + const freshContext = new UmbDocumentWorkspaceContext(hostElement); + await freshContext.load(VARIANT_DOCUMENT_ID); + expect(freshContext.getPropertyValue('variantText', EN_US), 'en-US saved on server').to.equal('Edited English'); + expect(freshContext.getPropertyValue('variantText', DA), 'da not saved on server').to.equal(DA_ORIGINAL); + }); + + it('uses the combined update-and-publish call, not the save-only update endpoint', async () => { + let updateAndPublishCalls = 0; + let updateCalls = 0; + const originalUpdateAndPublish = UmbDocumentPublishingServerDataSource.prototype.updateAndPublish; + const originalUpdate = UmbDocumentServerDataSource.prototype.update; + UmbDocumentPublishingServerDataSource.prototype.updateAndPublish = function (...args) { + updateAndPublishCalls++; + return originalUpdateAndPublish.apply(this, args as never); + }; + UmbDocumentServerDataSource.prototype.update = function (...args) { + updateCalls++; + return originalUpdate.apply(this, args as never); + }; + + try { + await context.setPropertyValue('variantText', 'Edited English', EN_US); + const saveData = await context.constructSaveData([EN_US]); + await publishingDataSource.updateAndPublish(saveData, [EN_US]); + } finally { + UmbDocumentPublishingServerDataSource.prototype.updateAndPublish = originalUpdateAndPublish; + UmbDocumentServerDataSource.prototype.update = originalUpdate; + } + + expect(updateAndPublishCalls, 'update-and-publish called once').to.equal(1); + expect(updateCalls, 'the plain update (save-only) endpoint is not called').to.equal(0); + }); +});