From 8e0912cbf1f6270d2dcccdb653b57237b4dcf234 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 1 Apr 2025 15:49:49 +0200 Subject: [PATCH 01/53] Only prevent the unpublish or delete of a related item when configured to do so if it is related as a child, not as a parent (#18886) * Only prevent the unpubkish or delete of a related item when configured to do so if it is related as a child, not as a parent. * Fixed incorect parameter names. * Fixed failing integration tests. * Use using variable instead to reduce nesting * Applied suggestions from code review. * Used simple using statement throughout RelationService for consistency. * Applied XML header comments consistently. --------- Co-authored-by: mole --- .../Models/RelationDirectionFilter.cs | 12 + .../Services/ContentEditingServiceBase.cs | 4 +- .../Services/ContentPublishingService.cs | 2 +- src/Umbraco.Core/Services/ContentService.cs | 2 +- src/Umbraco.Core/Services/IRelationService.cs | 12 + src/Umbraco.Core/Services/RelationService.cs | 472 ++++++++---------- .../ContentEditingServiceTests.Delete.cs | 40 +- ...entEditingServiceTests.MoveToRecycleBin.cs | 32 +- .../Services/ContentEditingServiceTests.cs | 8 + ...ContentPublishingServiceTests.Unpublish.cs | 44 +- .../Services/ContentPublishingServiceTests.cs | 4 +- 11 files changed, 343 insertions(+), 289 deletions(-) create mode 100644 src/Umbraco.Core/Models/RelationDirectionFilter.cs diff --git a/src/Umbraco.Core/Models/RelationDirectionFilter.cs b/src/Umbraco.Core/Models/RelationDirectionFilter.cs new file mode 100644 index 0000000000..1a71f8e070 --- /dev/null +++ b/src/Umbraco.Core/Models/RelationDirectionFilter.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Definition of relation directions used as a filter when requesting if a given item has relations. +/// +[Flags] +public enum RelationDirectionFilter +{ + Parent = 1, + Child = 2, + Any = Parent | Child +} diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs index 63232f7262..08d43e7235 100644 --- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; @@ -198,7 +198,7 @@ internal abstract class ContentEditingServiceBase(status, content)); } - if (disabledWhenReferenced && _relationService.IsRelated(content.Id)) + if (disabledWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) { return Attempt.FailWithStatus(referenceFailStatus, content); } diff --git a/src/Umbraco.Core/Services/ContentPublishingService.cs b/src/Umbraco.Core/Services/ContentPublishingService.cs index 4b81f8df49..f5c4a11cb5 100644 --- a/src/Umbraco.Core/Services/ContentPublishingService.cs +++ b/src/Umbraco.Core/Services/ContentPublishingService.cs @@ -311,7 +311,7 @@ internal sealed class ContentPublishingService : IContentPublishingService return Attempt.Fail(ContentPublishingOperationStatus.ContentNotFound); } - if (_contentSettings.DisableUnpublishWhenReferenced && _relationService.IsRelated(content.Id)) + if (_contentSettings.DisableUnpublishWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) { scope.Complete(); return Attempt.Fail(ContentPublishingOperationStatus.CannotUnpublishWhenReferenced); diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 9b42428938..f20c3df4af 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -2767,7 +2767,7 @@ public class ContentService : RepositoryService, IContentService { foreach (IContent content in contents) { - if (_contentSettings.DisableDeleteWhenReferenced && _relationService.IsRelated(content.Id)) + if (_contentSettings.DisableDeleteWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) { continue; } diff --git a/src/Umbraco.Core/Services/IRelationService.cs b/src/Umbraco.Core/Services/IRelationService.cs index 676e7ea17c..e556b2c3e4 100644 --- a/src/Umbraco.Core/Services/IRelationService.cs +++ b/src/Umbraco.Core/Services/IRelationService.cs @@ -297,8 +297,20 @@ public interface IRelationService : IService /// /// Id of an object to check relations for /// Returns True if any relations exists with the given Id, otherwise False + [Obsolete("Please use the overload taking a RelationDirectionFilter parameter. Scheduled for removal in Umbraco 17.")] bool IsRelated(int id); + /// + /// Checks whether any relations exists for the passed in Id and direction. + /// + /// Id of an object to check relations for + /// Indicates whether to check for relations as parent, child or in either direction. + /// Returns True if any relations exists with the given Id, otherwise False + bool IsRelated(int id, RelationDirectionFilter directionFilter) +#pragma warning disable CS0618 // Type or member is obsolete + => IsRelated(id); +#pragma warning restore CS0618 // Type or member is obsolete + /// /// Checks whether two items are related /// diff --git a/src/Umbraco.Core/Services/RelationService.cs b/src/Umbraco.Core/Services/RelationService.cs index 6c54ee6e54..ad419ade86 100644 --- a/src/Umbraco.Core/Services/RelationService.cs +++ b/src/Umbraco.Core/Services/RelationService.cs @@ -63,28 +63,22 @@ public class RelationService : RepositoryService, IRelationService /// public IRelation? GetById(int id) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationRepository.Get(id); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationRepository.Get(id); } /// public IRelationType? GetRelationTypeById(int id) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationTypeRepository.Get(id); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationTypeRepository.Get(id); } /// public IRelationType? GetRelationTypeById(Guid id) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationTypeRepository.Get(id); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationTypeRepository.Get(id); } /// @@ -93,10 +87,8 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetAllRelations(params int[] ids) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationRepository.GetMany(ids); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationRepository.GetMany(ids); } /// @@ -106,20 +98,16 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetAllRelationsByRelationType(int relationTypeId) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.RelationTypeId == relationTypeId); - return _relationRepository.Get(query); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.RelationTypeId == relationTypeId); + return _relationRepository.Get(query); } /// public IEnumerable GetAllRelationTypes(params int[] ids) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationTypeRepository.GetMany(ids); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationTypeRepository.GetMany(ids); } /// @@ -144,10 +132,8 @@ public class RelationService : RepositoryService, IRelationService .Take(take)); } - public int CountRelationTypes() - { - return _relationTypeRepository.Count(null); - } + /// + public int CountRelationTypes() => _relationTypeRepository.Count(null); /// public IEnumerable GetByParentId(int id) => GetByParentId(id, null); @@ -155,24 +141,22 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetByParentId(int id, string? relationTypeAlias) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + if (relationTypeAlias.IsNullOrWhiteSpace()) { - if (relationTypeAlias.IsNullOrWhiteSpace()) - { - IQuery qry1 = Query().Where(x => x.ParentId == id); - return _relationRepository.Get(qry1); - } - - IRelationType? relationType = GetRelationType(relationTypeAlias!); - if (relationType == null) - { - return Enumerable.Empty(); - } - - IQuery qry2 = - Query().Where(x => x.ParentId == id && x.RelationTypeId == relationType.Id); - return _relationRepository.Get(qry2); + IQuery qry1 = Query().Where(x => x.ParentId == id); + return _relationRepository.Get(qry1); } + + IRelationType? relationType = GetRelationType(relationTypeAlias!); + if (relationType == null) + { + return Enumerable.Empty(); + } + + IQuery qry2 = + Query().Where(x => x.ParentId == id && x.RelationTypeId == relationType.Id); + return _relationRepository.Get(qry2); } /// @@ -188,24 +172,22 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetByChildId(int id, string? relationTypeAlias) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + if (relationTypeAlias.IsNullOrWhiteSpace()) { - if (relationTypeAlias.IsNullOrWhiteSpace()) - { - IQuery qry1 = Query().Where(x => x.ChildId == id); - return _relationRepository.Get(qry1); - } - - IRelationType? relationType = GetRelationType(relationTypeAlias!); - if (relationType == null) - { - return Enumerable.Empty(); - } - - IQuery qry2 = - Query().Where(x => x.ChildId == id && x.RelationTypeId == relationType.Id); - return _relationRepository.Get(qry2); + IQuery qry1 = Query().Where(x => x.ChildId == id); + return _relationRepository.Get(qry1); } + + IRelationType? relationType = GetRelationType(relationTypeAlias!); + if (relationType == null) + { + return Enumerable.Empty(); + } + + IQuery qry2 = + Query().Where(x => x.ChildId == id && x.RelationTypeId == relationType.Id); + return _relationRepository.Get(qry2); } /// @@ -218,39 +200,34 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetByParentOrChildId(int id) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.ChildId == id || x.ParentId == id); - return _relationRepository.Get(query); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.ChildId == id || x.ParentId == id); + return _relationRepository.Get(query); } + /// public IEnumerable GetByParentOrChildId(int id, string relationTypeAlias) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IRelationType? relationType = GetRelationType(relationTypeAlias); + if (relationType == null) { - IRelationType? relationType = GetRelationType(relationTypeAlias); - if (relationType == null) - { - return Enumerable.Empty(); - } - - IQuery query = Query().Where(x => - (x.ChildId == id || x.ParentId == id) && x.RelationTypeId == relationType.Id); - return _relationRepository.Get(query); + return Enumerable.Empty(); } + + IQuery query = Query().Where(x => + (x.ChildId == id || x.ParentId == id) && x.RelationTypeId == relationType.Id); + return _relationRepository.Get(query); } /// public IRelation? GetByParentAndChildId(int parentId, int childId, IRelationType relationType) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.ParentId == parentId && - x.ChildId == childId && - x.RelationTypeId == relationType.Id); - return _relationRepository.Get(query).FirstOrDefault(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.ParentId == parentId && + x.ChildId == childId && + x.RelationTypeId == relationType.Id); + return _relationRepository.Get(query).FirstOrDefault(); } /// @@ -283,47 +260,41 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetByRelationTypeId(int relationTypeId) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.RelationTypeId == relationTypeId); - return _relationRepository.Get(query); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.RelationTypeId == relationTypeId); + return _relationRepository.Get(query); } /// public IEnumerable GetPagedByRelationTypeId(int relationTypeId, long pageIndex, int pageSize, out long totalRecords, Ordering? ordering = null) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery? query = Query().Where(x => x.RelationTypeId == relationTypeId); - return _relationRepository.GetPagedRelationsByQuery(query, pageIndex, pageSize, out totalRecords, ordering); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery? query = Query().Where(x => x.RelationTypeId == relationTypeId); + return _relationRepository.GetPagedRelationsByQuery(query, pageIndex, pageSize, out totalRecords, ordering); } + /// public async Task> GetPagedByChildKeyAsync(Guid childKey, int skip, int take, string? relationTypeAlias) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return await _relationRepository.GetPagedByChildKeyAsync(childKey, skip, take, relationTypeAlias); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return await _relationRepository.GetPagedByChildKeyAsync(childKey, skip, take, relationTypeAlias); } + /// public async Task, RelationOperationStatus>> GetPagedByRelationTypeKeyAsync(Guid key, int skip, int take, Ordering? ordering = null) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IRelationType? relationType = _relationTypeRepository.Get(key); + if (relationType is null) { - IRelationType? relationType = _relationTypeRepository.Get(key); - if (relationType is null) - { - return await Task.FromResult(Attempt.FailWithStatus, RelationOperationStatus>(RelationOperationStatus.RelationTypeNotFound, null!)); - } - - PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); - - IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); - IEnumerable relations = _relationRepository.GetPagedRelationsByQuery(query, pageNumber, pageSize, out var totalRecords, ordering); - return await Task.FromResult(Attempt.SucceedWithStatus(RelationOperationStatus.Success, new PagedModel(totalRecords, relations))); + return await Task.FromResult(Attempt.FailWithStatus, RelationOperationStatus>(RelationOperationStatus.RelationTypeNotFound, null!)); } + + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); + + IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); + IEnumerable relations = _relationRepository.GetPagedRelationsByQuery(query, pageNumber, pageSize, out var totalRecords, ordering); + return await Task.FromResult(Attempt.SucceedWithStatus(RelationOperationStatus.Success, new PagedModel(totalRecords, relations))); } /// @@ -394,19 +365,15 @@ public class RelationService : RepositoryService, IRelationService /// public IEnumerable GetPagedParentEntitiesByChildId(int id, long pageIndex, int pageSize, out long totalChildren, params UmbracoObjectTypes[] entityTypes) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationRepository.GetPagedParentEntitiesByChildId(id, pageIndex, pageSize, out totalChildren, entityTypes.Select(x => x.GetGuid()).ToArray()); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationRepository.GetPagedParentEntitiesByChildId(id, pageIndex, pageSize, out totalChildren, entityTypes.Select(x => x.GetGuid()).ToArray()); } /// public IEnumerable GetPagedChildEntitiesByParentId(int id, long pageIndex, int pageSize, out long totalChildren, params UmbracoObjectTypes[] entityTypes) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _relationRepository.GetPagedChildEntitiesByParentId(id, pageIndex, pageSize, out totalChildren, entityTypes.Select(x => x.GetGuid()).ToArray()); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return _relationRepository.GetPagedChildEntitiesByParentId(id, pageIndex, pageSize, out totalChildren, entityTypes.Select(x => x.GetGuid()).ToArray()); } /// @@ -440,22 +407,20 @@ public class RelationService : RepositoryService, IRelationService // TODO: We don't check if this exists first, it will throw some sort of data integrity exception if it already exists, is that ok? var relation = new Relation(parentId, childId, relationType); - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + EventMessages eventMessages = EventMessagesFactory.Get(); + var savingNotification = new RelationSavingNotification(relation, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) { - EventMessages eventMessages = EventMessagesFactory.Get(); - var savingNotification = new RelationSavingNotification(relation, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return relation; // TODO: returning sth that does not exist here?! - } - - _relationRepository.Save(relation); - scope.Notifications.Publish( - new RelationSavedNotification(relation, eventMessages).WithStateFrom(savingNotification)); scope.Complete(); - return relation; + return relation; // TODO: returning sth that does not exist here?! } + + _relationRepository.Save(relation); + scope.Notifications.Publish( + new RelationSavedNotification(relation, eventMessages).WithStateFrom(savingNotification)); + scope.Complete(); + return relation; } /// @@ -491,31 +456,37 @@ public class RelationService : RepositoryService, IRelationService /// public bool HasRelations(IRelationType relationType) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); - return _relationRepository.Get(query).Any(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); + return _relationRepository.Get(query).Any(); } /// - public bool IsRelated(int id) + public bool IsRelated(int id) => IsRelated(id, RelationDirectionFilter.Any); + + /// + public bool IsRelated(int id, RelationDirectionFilter directionFilter) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query(); + + query = directionFilter switch { - IQuery query = Query().Where(x => x.ParentId == id || x.ChildId == id); - return _relationRepository.Get(query).Any(); - } + RelationDirectionFilter.Parent => query.Where(x => x.ParentId == id), + RelationDirectionFilter.Child => query.Where(x => x.ChildId == id), + RelationDirectionFilter.Any => query.Where(x => x.ParentId == id || x.ChildId == id), + _ => throw new ArgumentOutOfRangeException(nameof(directionFilter)), + }; + + return _relationRepository.Get(query).Any(); } /// public bool AreRelated(int parentId, int childId) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.ParentId == parentId && x.ChildId == childId); - return _relationRepository.Get(query).Any(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.ParentId == parentId && x.ChildId == childId); + return _relationRepository.Get(query).Any(); } /// @@ -540,65 +511,61 @@ public class RelationService : RepositoryService, IRelationService /// public void Save(IRelation relation) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + EventMessages eventMessages = EventMessagesFactory.Get(); + var savingNotification = new RelationSavingNotification(relation, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) { - EventMessages eventMessages = EventMessagesFactory.Get(); - var savingNotification = new RelationSavingNotification(relation, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return; - } - - _relationRepository.Save(relation); scope.Complete(); - scope.Notifications.Publish( - new RelationSavedNotification(relation, eventMessages).WithStateFrom(savingNotification)); + return; } + + _relationRepository.Save(relation); + scope.Complete(); + scope.Notifications.Publish( + new RelationSavedNotification(relation, eventMessages).WithStateFrom(savingNotification)); } + /// public void Save(IEnumerable relations) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IRelation[] relationsA = relations.ToArray(); + + EventMessages messages = EventMessagesFactory.Get(); + var savingNotification = new RelationSavingNotification(relationsA, messages); + if (scope.Notifications.PublishCancelable(savingNotification)) { - IRelation[] relationsA = relations.ToArray(); - - EventMessages messages = EventMessagesFactory.Get(); - var savingNotification = new RelationSavingNotification(relationsA, messages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return; - } - - _relationRepository.Save(relationsA); scope.Complete(); - scope.Notifications.Publish( - new RelationSavedNotification(relationsA, messages).WithStateFrom(savingNotification)); + return; } + + _relationRepository.Save(relationsA); + scope.Complete(); + scope.Notifications.Publish( + new RelationSavedNotification(relationsA, messages).WithStateFrom(savingNotification)); } /// public void Save(IRelationType relationType) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + EventMessages eventMessages = EventMessagesFactory.Get(); + var savingNotification = new RelationTypeSavingNotification(relationType, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) { - EventMessages eventMessages = EventMessagesFactory.Get(); - var savingNotification = new RelationTypeSavingNotification(relationType, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return; - } - - _relationTypeRepository.Save(relationType); - Audit(AuditType.Save, Constants.Security.SuperUserId, relationType.Id, $"Saved relation type: {relationType.Name}"); scope.Complete(); - scope.Notifications.Publish( - new RelationTypeSavedNotification(relationType, eventMessages).WithStateFrom(savingNotification)); + return; } + + _relationTypeRepository.Save(relationType); + Audit(AuditType.Save, Constants.Security.SuperUserId, relationType.Id, $"Saved relation type: {relationType.Name}"); + scope.Complete(); + scope.Notifications.Publish( + new RelationTypeSavedNotification(relationType, eventMessages).WithStateFrom(savingNotification)); } + /// public async Task> CreateAsync(IRelationType relationType, Guid userKey) { if (relationType.Id != 0) @@ -614,6 +581,7 @@ public class RelationService : RepositoryService, IRelationService userKey); } + /// public async Task> UpdateAsync(IRelationType relationType, Guid userKey) => await SaveAsync( relationType, @@ -669,105 +637,97 @@ public class RelationService : RepositoryService, IRelationService /// public void Delete(IRelation relation) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + EventMessages eventMessages = EventMessagesFactory.Get(); + var deletingNotification = new RelationDeletingNotification(relation, eventMessages); + if (scope.Notifications.PublishCancelable(deletingNotification)) { - EventMessages eventMessages = EventMessagesFactory.Get(); - var deletingNotification = new RelationDeletingNotification(relation, eventMessages); - if (scope.Notifications.PublishCancelable(deletingNotification)) - { - scope.Complete(); - return; - } - - _relationRepository.Delete(relation); scope.Complete(); - scope.Notifications.Publish( - new RelationDeletedNotification(relation, eventMessages).WithStateFrom(deletingNotification)); + return; } + + _relationRepository.Delete(relation); + scope.Complete(); + scope.Notifications.Publish( + new RelationDeletedNotification(relation, eventMessages).WithStateFrom(deletingNotification)); } /// public void Delete(IRelationType relationType) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + EventMessages eventMessages = EventMessagesFactory.Get(); + var deletingNotification = new RelationTypeDeletingNotification(relationType, eventMessages); + if (scope.Notifications.PublishCancelable(deletingNotification)) { - EventMessages eventMessages = EventMessagesFactory.Get(); - var deletingNotification = new RelationTypeDeletingNotification(relationType, eventMessages); - if (scope.Notifications.PublishCancelable(deletingNotification)) - { - scope.Complete(); - return; - } - - _relationTypeRepository.Delete(relationType); scope.Complete(); - scope.Notifications.Publish( - new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification)); + return; } + + _relationTypeRepository.Delete(relationType); + scope.Complete(); + scope.Notifications.Publish( + new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification)); } + /// public async Task> DeleteAsync(Guid key, Guid userKey) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IRelationType? relationType = _relationTypeRepository.Get(key); + if (relationType is null) { - IRelationType? relationType = _relationTypeRepository.Get(key); - if (relationType is null) - { - return Attempt.FailWithStatus(RelationTypeOperationStatus.NotFound, null); - } - - EventMessages eventMessages = EventMessagesFactory.Get(); - var deletingNotification = new RelationTypeDeletingNotification(relationType, eventMessages); - if (scope.Notifications.PublishCancelable(deletingNotification)) - { - scope.Complete(); - return Attempt.FailWithStatus(RelationTypeOperationStatus.CancelledByNotification, null); - } - - _relationTypeRepository.Delete(relationType); - var currentUser = await _userIdKeyResolver.GetAsync(userKey); - Audit(AuditType.Delete, currentUser, relationType.Id, "Deleted relation type"); - scope.Notifications.Publish(new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification)); - scope.Complete(); - return await Task.FromResult(Attempt.SucceedWithStatus(RelationTypeOperationStatus.Success, relationType)); + return Attempt.FailWithStatus(RelationTypeOperationStatus.NotFound, null); } + + EventMessages eventMessages = EventMessagesFactory.Get(); + var deletingNotification = new RelationTypeDeletingNotification(relationType, eventMessages); + if (scope.Notifications.PublishCancelable(deletingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(RelationTypeOperationStatus.CancelledByNotification, null); + } + + _relationTypeRepository.Delete(relationType); + var currentUser = await _userIdKeyResolver.GetAsync(userKey); + Audit(AuditType.Delete, currentUser, relationType.Id, "Deleted relation type"); + scope.Notifications.Publish(new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification)); + scope.Complete(); + return await Task.FromResult(Attempt.SucceedWithStatus(RelationTypeOperationStatus.Success, relationType)); } /// public void DeleteRelationsOfType(IRelationType relationType) { var relations = new List(); - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); + var allRelations = _relationRepository.Get(query).ToList(); + relations.AddRange(allRelations); + + // TODO: N+1, we should be able to do this in a single call + foreach (IRelation relation in relations) { - IQuery query = Query().Where(x => x.RelationTypeId == relationType.Id); - var allRelations = _relationRepository.Get(query).ToList(); - relations.AddRange(allRelations); - - // TODO: N+1, we should be able to do this in a single call - foreach (IRelation relation in relations) - { - _relationRepository.Delete(relation); - } - - scope.Complete(); - - scope.Notifications.Publish(new RelationDeletedNotification(relations, EventMessagesFactory.Get())); + _relationRepository.Delete(relation); } + + scope.Complete(); + + scope.Notifications.Publish(new RelationDeletedNotification(relations, EventMessagesFactory.Get())); } + /// public bool AreRelated(int parentId, int childId, IRelationType relationType) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => - x.ParentId == parentId && x.ChildId == childId && x.RelationTypeId == relationType.Id); - return _relationRepository.Get(query).Any(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => + x.ParentId == parentId && x.ChildId == childId && x.RelationTypeId == relationType.Id); + return _relationRepository.Get(query).Any(); } + /// public IEnumerable GetAllowedObjectTypes() => - new[] - { + [ UmbracoObjectTypes.Document, UmbracoObjectTypes.Media, UmbracoObjectTypes.Member, @@ -778,17 +738,15 @@ public class RelationService : RepositoryService, IRelationService UmbracoObjectTypes.MemberGroup, UmbracoObjectTypes.ROOT, UmbracoObjectTypes.RecycleBin, - }; + ]; #region Private Methods private IRelationType? GetRelationType(string relationTypeAlias) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.Alias == relationTypeAlias); - return _relationTypeRepository.Get(query).FirstOrDefault(); - } + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = Query().Where(x => x.Alias == relationTypeAlias); + return _relationTypeRepository.Get(query).FirstOrDefault(); } private IEnumerable GetRelationsByListOfTypeIds(IEnumerable relationTypeIds) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs index 93395a768e..dd4a2452e4 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Delete.cs @@ -1,8 +1,7 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Tests.Integration.Attributes; @@ -12,29 +11,20 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; public partial class ContentEditingServiceTests { protected IRelationService RelationService => GetRequiredService(); + public static void ConfigureDisableDeleteWhenReferenced(IUmbracoBuilder builder) - { - builder.Services.Configure(config => + => builder.Services.Configure(config => config.DisableDeleteWhenReferenced = true); - } - - public void Relate(IContent child, IContent parent) - { - var relatedContentRelType = RelationService.GetRelationTypeByAlias(Constants.Conventions.RelationTypes.RelatedDocumentAlias); - - var relation = RelationService.Relate(child.Id, parent.Id, relatedContentRelType); - RelationService.Save(relation); - } - [Test] [ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))] - public async Task Cannot_Delete_Referenced_Content() + public async Task Cannot_Delete_When_Content_Is_Related_As_A_Child_And_Configured_To_Disable_When_Related() { var moveAttempt = await ContentEditingService.MoveToRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); Assert.IsTrue(moveAttempt.Success); - Relate(Subpage, Subpage2); + // Setup a relation where the page being deleted is related to another page as a child (e.g. the other page has a picker and has selected this page). + Relate(Subpage2, Subpage); var result = await ContentEditingService.DeleteFromRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.CannotDeleteWhenReferenced, result.Status); @@ -44,6 +34,24 @@ public partial class ContentEditingServiceTests Assert.IsNotNull(subpage); } + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableDeleteWhenReferenced))] + public async Task Can_Delete_When_Content_Is_Related_As_A_Parent_And_Configured_To_Disable_When_Related() + { + var moveAttempt = await ContentEditingService.MoveToRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveAttempt.Success); + + // Setup a relation where the page being deleted is related to another page as a child (e.g. the other page has a picker and has selected this page). + Relate(Subpage, Subpage2); + var result = await ContentEditingService.DeleteFromRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + // re-get and verify deleted + var subpage = await ContentEditingService.GetAsync(Subpage.Key); + Assert.IsNull(subpage); + } + [TestCase(true)] [TestCase(false)] public async Task Can_Delete_FromRecycleBin(bool variant) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs index ac7700131f..35a5b3244f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.MoveToRecycleBin.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -9,12 +9,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; public partial class ContentEditingServiceTests { - - public static void ConfigureDisableDelete(IUmbracoBuilder builder) - { - builder.Services.Configure(config => + public static new void ConfigureDisableUnpublishWhenReferencedTrue(IUmbracoBuilder builder) + => builder.Services.Configure(config => config.DisableUnpublishWhenReferenced = true); - } [TestCase(true)] [TestCase(false)] @@ -33,10 +30,11 @@ public partial class ContentEditingServiceTests } [Test] - [ConfigureBuilder(ActionName = nameof(ConfigureDisableDelete))] - public async Task Cannot_Move_To_Recycle_Bin_If_Referenced() + [ConfigureBuilder(ActionName = nameof(ConfigureDisableUnpublishWhenReferencedTrue))] + public async Task Cannot_Move_To_Recycle_Bin_When_Content_Is_Related_As_A_Child_And_Configured_To_Disable_When_Related() { - Relate(Subpage, Subpage2); + // Setup a relation where the page being deleted is related to another page as a child (e.g. the other page has a picker and has selected this page). + Relate(Subpage2, Subpage); var moveAttempt = await ContentEditingService.MoveToRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); Assert.IsFalse(moveAttempt.Success); Assert.AreEqual(ContentEditingOperationStatus.CannotMoveToRecycleBinWhenReferenced, moveAttempt.Status); @@ -47,6 +45,22 @@ public partial class ContentEditingServiceTests Assert.IsFalse(content.Trashed); } + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableUnpublishWhenReferencedTrue))] + public async Task Can_Move_To_Recycle_Bin_When_Content_Is_Related_As_A_Parent_And_Configured_To_Disable_When_Related() + { + // Setup a relation where the page being deleted is related to another page as a child (e.g. the other page has a picker and has selected this page). + Relate(Subpage, Subpage2); + var moveAttempt = await ContentEditingService.MoveToRecycleBinAsync(Subpage.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(moveAttempt.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, moveAttempt.Status); + + // re-get and verify moved + var content = await ContentEditingService.GetAsync(Subpage.Key); + Assert.IsNotNull(content); + Assert.IsTrue(content.Trashed); + } + [Test] public async Task Cannot_Move_Non_Existing_To_Recycle_Bin() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs index 68daf63c8d..67daf08ae5 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs @@ -16,6 +16,14 @@ public partial class ContentEditingServiceTests : ContentEditingServiceTestsBase [SetUp] public void Setup() => ContentRepositoryBase.ThrowOnWarning = true; + public void Relate(IContent parent, IContent child) + { + var relatedContentRelType = RelationService.GetRelationTypeByAlias(Constants.Conventions.RelationTypes.RelatedDocumentAlias); + + var relation = RelationService.Relate(parent.Id, child.Id, relatedContentRelType); + RelationService.Save(relation); + } + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddNotificationHandler(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Unpublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Unpublish.cs index 38c3c03829..c292727785 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Unpublish.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Unpublish.cs @@ -1,14 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Integration.Attributes; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; public partial class ContentPublishingServiceTests { + public static new void ConfigureDisableUnpublishWhenReferencedTrue(IUmbracoBuilder builder) + => builder.Services.Configure(config => + config.DisableUnpublishWhenReferenced = true); + [Test] public async Task Can_Unpublish_Root() { @@ -92,6 +100,40 @@ public partial class ContentPublishingServiceTests VerifyIsPublished(Textpage.Key); } + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableUnpublishWhenReferencedTrue))] + public async Task Cannot_Unpublish_When_Content_Is_Related_As_A_Child_And_Configured_To_Disable_When_Related() + { + await ContentPublishingService.PublishAsync(Textpage.Key, MakeModel(_allCultures), Constants.Security.SuperUserKey); + VerifyIsPublished(Textpage.Key); + + // Setup a relation where the page being unpublished is related to another page as a child (e.g. the other page has a picker and has selected this page). + RelationService.Relate(Subpage, Textpage, Constants.Conventions.RelationTypes.RelatedDocumentAlias); + + var result = await ContentPublishingService.UnpublishAsync(Textpage.Key, null, Constants.Security.SuperUserKey); + + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentPublishingOperationStatus.CannotUnpublishWhenReferenced, result.Result); + VerifyIsPublished(Textpage.Key); + } + + [Test] + [ConfigureBuilder(ActionName = nameof(ConfigureDisableUnpublishWhenReferencedTrue))] + public async Task Can_Unpublish_When_Content_Is_Related_As_A_Parent_And_Configured_To_Disable_When_Related() + { + await ContentPublishingService.PublishAsync(Textpage.Key, MakeModel(_allCultures), Constants.Security.SuperUserKey); + VerifyIsPublished(Textpage.Key); + + // Setup a relation where the page being unpublished is related to another page as a parent (e.g. this page has a picker and has selected the other page). + RelationService.Relate(Textpage, Subpage, Constants.Conventions.RelationTypes.RelatedDocumentAlias); + + var result = await ContentPublishingService.UnpublishAsync(Textpage.Key, null, Constants.Security.SuperUserKey); + + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result); + VerifyIsNotPublished(Textpage.Key); + } + [Test] public async Task Can_Unpublish_Single_Culture() { @@ -229,8 +271,6 @@ public partial class ContentPublishingServiceTests Assert.AreEqual(0, content.PublishedCultures.Count()); } - - [Test] public async Task Can_Unpublish_Non_Mandatory_Cultures() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs index 6e5cf55fc7..fcfbd65b1b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -20,6 +20,8 @@ public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithC { private IContentPublishingService ContentPublishingService => GetRequiredService(); + private IRelationService RelationService => GetRequiredService(); + private static readonly ISet _allCultures = new HashSet(){ "*" }; private static CultureAndScheduleModel MakeModel(ISet cultures) => new CultureAndScheduleModel() From 11c19847cf3afba9351bb9696d1dec28a27caa8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 2 Apr 2025 06:25:34 +0200 Subject: [PATCH 02/53] Feature: highlight invariant doc with variant blocks is unsupported (#18806) * mark variant blocks in invariant docs as invalid * implement RTE Blocks --- .../src/assets/lang/en.ts | 2 + .../property-editor-ui-block-grid.element.ts | 47 +++++++++++++++++ .../property-editor-ui-block-list.element.ts | 51 ++++++++++++++++++- .../context/validation-messages.manager.ts | 10 +++- ...r-validation-to-form-control.controller.ts | 2 +- .../rte/components/rte-base.element.ts | 37 ++++++++++++++ 6 files changed, 146 insertions(+), 3 deletions(-) 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 e610a10f35..9bd45b6275 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -2657,6 +2657,8 @@ export default { unsupportedBlockName: 'Unsupported', unsupportedBlockDescription: 'This content is no longer supported in this Editor. If you are missing this content, please contact your administrator. Otherwise delete it.', + blockVariantConfigurationNotSupported: + 'One or more Block Types of this Block Editor is using a Element-Type that is configured to Vary By Culture or Vary By Segment. This is not supported on a Content item that does not vary by Culture or Segment.', }, contentTemplatesDashboard: { whatHeadline: 'What are Document Blueprints?', diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts index b156099843..3434ca2286 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts @@ -9,6 +9,7 @@ import { css, type PropertyValueMap, ref, + nothing, } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { @@ -24,6 +25,7 @@ import { debounceTime } from '@umbraco-cms/backoffice/external/rxjs'; // TODO: consider moving the components to the property editor folder as they are only used here import '../../local-components.js'; +import { UMB_CONTENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content'; /** * @element umb-property-editor-ui-block-grid @@ -85,9 +87,51 @@ export class UmbPropertyEditorUIBlockGridElement return super.value; } + @state() + _notSupportedVariantSetting?: boolean; + constructor() { super(); + this.consumeContext(UMB_CONTENT_WORKSPACE_CONTEXT, (context) => { + this.observe( + observeMultiple([ + this.#managerContext.blockTypes, + context.structure.variesByCulture, + context.structure.variesBySegment, + ]), + async ([blockTypes, variesByCulture, variesBySegment]) => { + if (blockTypes.length > 0 && (variesByCulture === false || variesBySegment === false)) { + // check if any of the Blocks varyByCulture or Segment and then display a warning. + const promises = await Promise.all( + blockTypes.map(async (blockType) => { + const elementType = blockType.contentElementTypeKey; + await this.#managerContext.contentTypesLoaded; + const structure = await this.#managerContext.getStructure(elementType); + if (variesByCulture === false && structure?.getVariesByCulture() === true) { + // If block varies by culture but document does not. + return true; + } else if (variesBySegment === false && structure?.getVariesBySegment() === true) { + // If block varies by segment but document does not. + return true; + } + return false; + }), + ); + this._notSupportedVariantSetting = promises.filter((x) => x === true).length > 0; + + if (this._notSupportedVariantSetting) { + this.#validationContext.messages.addMessage( + 'config', + '$', + '#blockEditor_blockVariantConfigurationNotSupported', + ); + } + } + }, + ); + }).passContextAliasMatches(); + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { this.observe( context.dataPath, @@ -195,6 +239,9 @@ export class UmbPropertyEditorUIBlockGridElement } override render() { + if (this._notSupportedVariantSetting) { + return nothing; + } return html` = { getUniqueOfElement: (element) => { @@ -110,6 +111,8 @@ export class UmbPropertyEditorUIBlockListElement this.#managerContext.contentTypesLoaded.then(() => { const firstContentTypeName = this.#managerContext.getContentTypeNameOf(blocks[0].contentElementTypeKey); this._createButtonLabel = this.localize.term('blockEditor_addThis', this.localize.string(firstContentTypeName)); + + // If we are in a invariant context: }); } } @@ -157,9 +160,52 @@ export class UmbPropertyEditorUIBlockListElement readonly #managerContext = new UmbBlockListManagerContext(this); readonly #entriesContext = new UmbBlockListEntriesContext(this); + @state() + _notSupportedVariantSetting?: boolean; + constructor() { super(); + this.consumeContext(UMB_CONTENT_WORKSPACE_CONTEXT, (context) => { + this.observe( + observeMultiple([ + this.#managerContext.blockTypes, + context.structure.variesByCulture, + context.structure.variesBySegment, + ]), + async ([blockTypes, variesByCulture, variesBySegment]) => { + if (blockTypes.length > 0 && (variesByCulture === false || variesBySegment === false)) { + // check if any of the Blocks varyByCulture or Segment and then display a warning. + const promises = await Promise.all( + blockTypes.map(async (blockType) => { + const elementType = blockType.contentElementTypeKey; + await this.#managerContext.contentTypesLoaded; + const structure = await this.#managerContext.getStructure(elementType); + if (variesByCulture === false && structure?.getVariesByCulture() === true) { + // If block varies by culture but document does not. + return true; + } else if (variesBySegment === false && structure?.getVariesBySegment() === true) { + // If block varies by segment but document does not. + return true; + } + return false; + }), + ); + this._notSupportedVariantSetting = promises.filter((x) => x === true).length > 0; + + if (this._notSupportedVariantSetting) { + this.#validationContext.messages.addMessage( + 'config', + '$', + '#blockEditor_blockVariantConfigurationNotSupported', + 'blockConfigurationNotSupported', + ); + } + } + }, + ); + }).passContextAliasMatches(); + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { this.#gotPropertyContext(context); }); @@ -193,7 +239,7 @@ export class UmbPropertyEditorUIBlockListElement null, ); - this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => { + this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, async (context) => { this.#managerContext.setVariantId(context.getVariantId()); }); @@ -334,6 +380,9 @@ export class UmbPropertyEditorUIBlockListElement } override render() { + if (this._notSupportedVariantSetting) { + return nothing; + } return html` ${repeat( this._layouts, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts index b9e152688a..ad57584422 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/validation-messages.manager.ts @@ -3,7 +3,7 @@ import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import { UmbId } from '@umbraco-cms/backoffice/id'; import { UmbArrayState, createObservablePart } from '@umbraco-cms/backoffice/observable-api'; -export type UmbValidationMessageType = 'client' | 'server'; +export type UmbValidationMessageType = 'client' | 'server' | 'config' | string; export interface UmbValidationMessage { type: UmbValidationMessageType; key: string; @@ -95,6 +95,14 @@ export class UmbValidationMessagesManager { ); } + messagesOfNotTypeAndPath(type: UmbValidationMessageType, path: string): Observable> { + //path = path.toLowerCase(); + // Find messages that matches the given type and path. + return createObservablePart(this.filteredMessages, (msgs) => + msgs.filter((x) => x.type !== type && x.path === path), + ); + } + hasMessagesOfPathAndDescendant(path: string): Observable { //path = path.toLowerCase(); return createObservablePart(this.filteredMessages, (msgs) => diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts index 6dbb7c465c..7e91dfdd14 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts @@ -43,7 +43,7 @@ export class UmbBindServerValidationToFormControl extends UmbControllerBase { this.#context = context; this.observe( - context.messages?.messagesOfTypeAndPath('server', dataPath), + context.messages?.messagesOfNotTypeAndPath('client', dataPath), (messages) => { this.#messages = messages ?? []; this.#isValid = this.#messages.length === 0; diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts index 0749d8fbd2..fcf6be124e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts @@ -16,6 +16,7 @@ import { UmbValidationContext, } from '@umbraco-cms/backoffice/validation'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UMB_CONTENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content'; export abstract class UmbPropertyEditorUiRteElementBase extends UmbFormControlMixin(UmbLitElement) @@ -114,6 +115,42 @@ export abstract class UmbPropertyEditorUiRteElementBase constructor() { super(); + this.consumeContext(UMB_CONTENT_WORKSPACE_CONTEXT, (context) => { + this.observe( + observeMultiple([ + this.#managerContext.blockTypes, + context.structure.variesByCulture, + context.structure.variesBySegment, + ]), + async ([blockTypes, variesByCulture, variesBySegment]) => { + if (blockTypes.length > 0 && (variesByCulture === false || variesBySegment === false)) { + // check if any of the Blocks varyByCulture or Segment and then display a warning. + const promises = await Promise.all( + blockTypes.map(async (blockType) => { + const elementType = blockType.contentElementTypeKey; + await this.#managerContext.contentTypesLoaded; + const structure = await this.#managerContext.getStructure(elementType); + if (variesByCulture === false && structure?.getVariesByCulture() === true) { + // If block varies by culture but document does not. + return true; + } else if (variesBySegment === false && structure?.getVariesBySegment() === true) { + // If block varies by segment but document does not. + return true; + } + return false; + }), + ); + const notSupportedVariantSetting = promises.filter((x) => x === true).length > 0; + + if (notSupportedVariantSetting) { + this.setCustomValidity('#blockEditor_blockVariantConfigurationNotSupported'); + this.checkValidity(); + } + } + }, + ); + }).passContextAliasMatches(); + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { this.#gotPropertyContext(context); }); From 9d30d5b11cf833eacaa2c6e43c13389985258a5d Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Wed, 2 Apr 2025 09:34:52 +0200 Subject: [PATCH 03/53] Fix pagination for users restricted by start nodes (#18907) * Fix pagination for users restricted by start nodes * Default implementation to avoid breakage * Review comments * Fix failing test * Add media start node tests --- .../Tree/UserStartNodeTreeControllerBase.cs | 20 +- .../Entities/IUserStartNodeEntitiesService.cs | 32 ++ .../Entities/UserStartNodeEntitiesService.cs | 72 +++- ...erviceMediaTests.RootUserAccessEntities.cs | 85 +++++ ...rviceMediaTests.childUserAccessEntities.cs | 336 ++++++++++++++++++ .../UserStartNodeEntitiesServiceMediaTests.cs | 134 +++++++ ...iesServiceTests.ChildUserAccessEntities.cs | 336 ++++++++++++++++++ ...tiesServiceTests.RootUserAccessEntities.cs | 85 +++++ .../UserStartNodeEntitiesServiceTests.cs | 134 +++++++ .../Umbraco.Tests.Integration.csproj | 16 +- 10 files changed, 1241 insertions(+), 9 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs index d7a803b584..8c15708f1f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs @@ -42,11 +42,21 @@ public abstract class UserStartNodeTreeControllerBase : EntityTreeControl protected override IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems) { - IEntitySlim[] children = base.GetPagedChildEntities(parentKey, skip, take, out totalItems); - return UserHasRootAccess() || IgnoreUserStartNodes() - ? children - // Keeping the correct totalItems amount from GetPagedChildEntities - : CalculateAccessMap(() => _userStartNodeEntitiesService.ChildUserAccessEntities(children, UserStartNodePaths), out _); + if (UserHasRootAccess() || IgnoreUserStartNodes()) + { + return base.GetPagedChildEntities(parentKey, skip, take, out totalItems); + } + + IEnumerable userAccessEntities = _userStartNodeEntitiesService.ChildUserAccessEntities( + ItemObjectType, + UserStartNodePaths, + parentKey, + skip, + take, + ItemOrdering, + out totalItems); + + return CalculateAccessMap(() => userAccessEntities, out _); } protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities) diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs index 0a00d2e963..2753bd29b8 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs @@ -16,9 +16,41 @@ public interface IUserStartNodeEntitiesService /// /// The returned entities may include entities that outside of the user start node scope, but are needed to /// for browsing to the actual user start nodes. These entities will be marked as "no access" entities. + /// + /// This method does not support pagination, because it must load all entities explicitly in order to calculate + /// the correct result, given that user start nodes can be descendants of root nodes. Consumers need to apply + /// pagination to the result if applicable. /// IEnumerable RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds); + /// + /// Calculates the applicable child entities for a given object type for users without root access. + /// + /// The object type. + /// The calculated start node paths for the user. + /// The key of the parent. + /// The number of applicable children to skip. + /// The number of applicable children to take. + /// The ordering to apply when fetching and paginating the children. + /// The total number of applicable children available. + /// A list of child entities applicable for the user. + /// + /// The returned entities may include entities that outside of the user start node scope, but are needed to + /// for browsing to the actual user start nodes. These entities will be marked as "no access" entities. + /// + IEnumerable ChildUserAccessEntities( + UmbracoObjectTypes umbracoObjectType, + string[] userStartNodePaths, + Guid parentKey, + int skip, + int take, + Ordering ordering, + out long totalItems) + { + totalItems = 0; + return []; + } + /// /// Calculates the applicable child entities from a list of candidate child entities for users without root access. /// diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs index 1a5572303b..c811d2c9c7 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs @@ -1,8 +1,12 @@ -using Umbraco.Cms.Core; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Api.Management.Models.Entities; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Scoping; using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Services.Entities; @@ -10,8 +14,24 @@ namespace Umbraco.Cms.Api.Management.Services.Entities; public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService { private readonly IEntityService _entityService; + private readonly ICoreScopeProvider _scopeProvider; + private readonly IIdKeyMap _idKeyMap; - public UserStartNodeEntitiesService(IEntityService entityService) => _entityService = entityService; + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V17.")] + public UserStartNodeEntitiesService(IEntityService entityService) + : this( + entityService, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public UserStartNodeEntitiesService(IEntityService entityService, ICoreScopeProvider scopeProvider, IIdKeyMap idKeyMap) + { + _entityService = entityService; + _scopeProvider = scopeProvider; + _idKeyMap = idKeyMap; + } /// public IEnumerable RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds) @@ -43,6 +63,54 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService .ToArray(); } + public IEnumerable ChildUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid parentKey, int skip, int take, Ordering ordering, out long totalItems) + { + Attempt parentIdAttempt = _idKeyMap.GetIdForKey(parentKey, umbracoObjectType); + if (parentIdAttempt.Success is false) + { + totalItems = 0; + return []; + } + + var parentId = parentIdAttempt.Result; + IEntitySlim? parent = _entityService.Get(parentId); + if (parent is null) + { + totalItems = 0; + return []; + } + + IEntitySlim[] children; + if (userStartNodePaths.Any(path => $"{parent.Path},".StartsWith($"{path},"))) + { + // the requested parent is one of the user start nodes (or a descendant of one), all children are by definition allowed + children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, ordering: ordering).ToArray(); + return ChildUserAccessEntities(children, userStartNodePaths); + } + + // if one or more of the user start nodes are descendants of the requested parent, find the "next child IDs" in those user start node paths + // - e.g. given the user start node path "-1,2,3,4,5", if the requested parent ID is 3, the "next child ID" is 4. + var userStartNodePathIds = userStartNodePaths.Select(path => path.Split(Constants.CharArrays.Comma).Select(int.Parse).ToArray()).ToArray(); + var allowedChildIds = userStartNodePathIds + .Where(ids => ids.Contains(parentId)) + // given the previous checks, the parent ID can never be the last in the user start node path, so this is safe + .Select(ids => ids[ids.IndexOf(parentId) + 1]) + .Distinct() + .ToArray(); + + totalItems = allowedChildIds.Length; + if (allowedChildIds.Length == 0) + { + // the requested parent is outside the scope of any user start nodes + return []; + } + + // even though we know the IDs of the allowed child entities to fetch, we still use a Query to yield correctly sorted children + IQuery query = _scopeProvider.CreateQuery().Where(x => allowedChildIds.Contains(x.Id)); + children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, query, ordering).ToArray(); + return ChildUserAccessEntities(children, userStartNodePaths); + } + /// public IEnumerable ChildUserAccessEntities(IEnumerable candidateChildren, string[] userStartNodePaths) // child entities for users without root access should include: diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs new file mode 100644 index 0000000000..a6ab1ff131 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs @@ -0,0 +1,85 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceMediaTests +{ + [Test] + public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1"].Id, _mediaByName["5"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Media, + contentStartNodeIds) + .ToArray(); + + // expected total is 2, because only two items at root ("1" amd "10") are allowed + Assert.AreEqual(2, roots.Length); + Assert.Multiple(() => + { + // first and last content items are the ones allowed + Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_mediaByName["5"].Key, roots[1].Entity.Key); + + // explicitly verify the entity sort order, both so we know sorting works, + // and so we know it's actually the first and last item at root + Assert.AreEqual(0, roots[0].Entity.SortOrder); + Assert.AreEqual(4, roots[1].Entity.SortOrder); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(roots[0].HasAccess); + Assert.IsTrue(roots[1].HasAccess); + }); + } + + [Test] + public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_AsNotAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1-3"].Id, _mediaByName["3-3"].Id, _mediaByName["5-3"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Media, + contentStartNodeIds) + .ToArray(); + + Assert.AreEqual(3, roots.Length); + Assert.Multiple(() => + { + // the three start nodes are the children of the "1", "3" and "5" roots, respectively, so these are expected as roots + Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_mediaByName["3"].Key, roots[1].Entity.Key); + Assert.AreEqual(_mediaByName["5"].Key, roots[2].Entity.Key); + + // all are disallowed - only the children (the actual start nodes) are allowed + Assert.IsTrue(roots.All(r => r.HasAccess is false)); + }); + } + + [Test] + public async Task RootUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildRoots_AsNotAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1-2-3"].Id, _mediaByName["2-3-4"].Id, _mediaByName["3-4-5"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Media, + contentStartNodeIds) + .ToArray(); + + Assert.AreEqual(3, roots.Length); + Assert.Multiple(() => + { + // the three start nodes are the grandchildren of the "1", "2" and "3" roots, respectively, so these are expected as roots + Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_mediaByName["2"].Key, roots[1].Entity.Key); + Assert.AreEqual(_mediaByName["3"].Key, roots[2].Entity.Key); + + // all are disallowed - only the grandchildren (the actual start nodes) are allowed + Assert.IsTrue(roots.All(r => r.HasAccess is false)); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs new file mode 100644 index 0000000000..4df5ecbab7 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs @@ -0,0 +1,336 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceMediaTests +{ + [Test] + public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFirstPage_AsAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-1"].Id, _mediaByName["1-10"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + // expected total is 2, because only two items under "1" are allowed (note the page size is 3 for good measure) + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // first and last media items are the ones allowed + Assert.AreEqual(_mediaByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["1-10"].Key, children[1].Entity.Key); + + // explicitly verify the entity sort order, both so we know sorting works, + // and so we know it's actually the first and last item below "1" + Assert.AreEqual(0, children[0].Entity.SortOrder); + Assert.AreEqual(9, children[1].Entity.SortOrder); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInScope() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id, _mediaByName["2-10"].Id); + Assert.AreEqual(2, mediaStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["2"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(1, totalItems); + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + // only the "2-10" media item is returned, as "1-5" is out of scope + Assert.AreEqual(_mediaByName["2-10"].Key, children[0].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id, _mediaByName["2-10"].Id); + Assert.AreEqual(2, mediaStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(0, totalItems); + Assert.AreEqual(0, children.Length); + } + + [Test] + public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginate() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths( + _mediaByName["1-1"].Id, + _mediaByName["1-3"].Id, + _mediaByName["1-5"].Id, + _mediaByName["1-7"].Id, + _mediaByName["1-9"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1"].Key, + 0, + 2, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is 2 + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["1-3"].Key, children[1].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + + // next result page + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1"].Key, + 2, + 2, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is still 2 + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName["1-5"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["1-7"].Key, children[1].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + + // next result page + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1"].Key, + 4, + 2, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is still 2, but this is the last result page + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName["1-9"].Key, children[0].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchildren_AsAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3-3"].Key, + 0, + 100, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + Assert.AreEqual(5, children.Length); + Assert.Multiple(() => + { + // all children of "3-3" should be allowed because "3-3" is a start node + foreach (var childNumber in Enumerable.Range(1, 5)) + { + var child = children[childNumber - 1]; + Assert.AreEqual(_mediaByName[$"3-3-{childNumber}"].Key, child.Entity.Key); + Assert.IsTrue(child.HasAccess); + } + }); + } + + [Test] + public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildren_AsAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3-3"].Id, _mediaByName["3-3-4"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3-3"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3-3" - that is, the actual start nodes + Assert.AreEqual(_mediaByName["3-3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["3-3-4"].Key, children[1].Entity.Key); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsChildren_AsNotAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3-3"].Id, _mediaByName["3-5-3"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3" - that is, the parents of the actual start nodes + Assert.AreEqual(_mediaByName["3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["3-5"].Key, children[1].Entity.Key); + + // both are disallowed - only the two children (the actual start nodes) are allowed + Assert.IsFalse(children[0].HasAccess); + Assert.IsFalse(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild() + { + // NOTE: this test proves that start node paths are *not* additive when structural inheritance is in play. + // if one has a start node that is a descendant to another start node, the descendant start node "wins" + // and the ancestor start node is ignored. this differs somewhat from the norm; we normally consider + // permissions additive (which in this case would mean ignoring the descendant rather than the ancestor). + + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id, _mediaByName["3-3-1"].Id, _mediaByName["3-3-5"].Id); + Assert.AreEqual(2, mediaStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + Assert.AreEqual(1, totalItems); + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName["3-3"].Key, children[0].Entity.Key); + Assert.IsFalse(children[0].HasAccess); + }); + + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3-3"].Key, + 0, + 10, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3-3" - that is, the actual start nodes + Assert.AreEqual(_mediaByName["3-3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["3-3-5"].Key, children[1].Entity.Key); + + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectResultOrder() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id, _mediaByName["3-2"].Id, _mediaByName["3-1"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + Assert.AreEqual(3, totalItems); + Assert.AreEqual(3, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName["3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_mediaByName["3-2"].Key, children[1].Entity.Key); + Assert.AreEqual(_mediaByName["3-3"].Key, children[2].Entity.Key); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.cs new file mode 100644 index 0000000000..44a27bd7fb --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] +public partial class UserStartNodeEntitiesServiceMediaTests : UmbracoIntegrationTest +{ + private Dictionary _mediaByName = new (); + private IUserGroup _userGroup; + + private IMediaService MediaService => GetRequiredService(); + + private IMediaTypeService MediaTypeService => GetRequiredService(); + + private IUserGroupService UserGroupService => GetRequiredService(); + + private IUserService UserService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + private IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService(); + + protected readonly Ordering BySortOrder = Ordering.By("sortOrder"); + + protected override void ConfigureTestServices(IServiceCollection services) + { + base.ConfigureTestServices(services); + services.AddTransient(); + } + + [SetUp] + public async Task SetUpTestAsync() + { + if (_mediaByName.Any()) + { + return; + } + + var mediaType = new MediaTypeBuilder() + .WithAlias("theMediaType") + .Build(); + mediaType.AllowedAsRoot = true; + await MediaTypeService.CreateAsync(mediaType, Constants.Security.SuperUserKey); + mediaType.AllowedContentTypes = [new() { Alias = mediaType.Alias, Key = mediaType.Key }]; + await MediaTypeService.UpdateAsync(mediaType, Constants.Security.SuperUserKey); + + foreach (var rootNumber in Enumerable.Range(1, 5)) + { + var root = new MediaBuilder() + .WithMediaType(mediaType) + .WithName($"{rootNumber}") + .Build(); + MediaService.Save(root); + _mediaByName[root.Name!] = root; + + foreach (var childNumber in Enumerable.Range(1, 10)) + { + var child = new MediaBuilder() + .WithMediaType(mediaType) + .WithName($"{rootNumber}-{childNumber}") + .Build(); + child.SetParent(root); + MediaService.Save(child); + _mediaByName[child.Name!] = child; + + foreach (var grandChildNumber in Enumerable.Range(1, 5)) + { + var grandchild = new MediaBuilder() + .WithMediaType(mediaType) + .WithName($"{rootNumber}-{childNumber}-{grandChildNumber}") + .Build(); + grandchild.SetParent(child); + MediaService.Save(grandchild); + _mediaByName[grandchild.Name!] = grandchild; + } + } + } + + _userGroup = new UserGroupBuilder() + .WithAlias("theGroup") + .WithAllowedSections(["media"]) + .Build(); + _userGroup.StartMediaId = null; + await UserGroupService.CreateAsync(_userGroup, Constants.Security.SuperUserKey); + } + + private async Task CreateUserAndGetStartNodePaths(params int[] startNodeIds) + { + var user = await CreateUser(startNodeIds); + + var mediaStartNodePaths = user.GetMediaStartNodePaths(EntityService, AppCaches.NoCache); + Assert.IsNotNull(mediaStartNodePaths); + + return mediaStartNodePaths; + } + + private async Task CreateUserAndGetStartNodeIds(params int[] startNodeIds) + { + var user = await CreateUser(startNodeIds); + + var mediaStartNodeIds = user.CalculateMediaStartNodeIds(EntityService, AppCaches.NoCache); + Assert.IsNotNull(mediaStartNodeIds); + + return mediaStartNodeIds; + } + + private async Task CreateUser(int[] startNodeIds) + { + var user = new UserBuilder() + .WithName(Guid.NewGuid().ToString("N")) + .WithStartMediaIds(startNodeIds) + .Build(); + UserService.Save(user); + + var attempt = await UserGroupService.AddUsersToUserGroupAsync( + new UsersToUserGroupManipulationModel(_userGroup.Key, [user.Key]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(attempt.Success); + return user; + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs new file mode 100644 index 0000000000..2272b60bbd --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs @@ -0,0 +1,336 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceTests +{ + [Test] + public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFirstPage_AsAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-1"].Id, _contentByName["1-10"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + // expected total is 2, because only two items under "1" are allowed (note the page size is 3 for good measure) + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // first and last content items are the ones allowed + Assert.AreEqual(_contentByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["1-10"].Key, children[1].Entity.Key); + + // explicitly verify the entity sort order, both so we know sorting works, + // and so we know it's actually the first and last item below "1" + Assert.AreEqual(0, children[0].Entity.SortOrder); + Assert.AreEqual(9, children[1].Entity.SortOrder); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInScope() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id, _contentByName["2-10"].Id); + Assert.AreEqual(2, contentStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["2"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(1, totalItems); + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + // only the "2-10" content item is returned, as "1-5" is out of scope + Assert.AreEqual(_contentByName["2-10"].Key, children[0].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id, _contentByName["2-10"].Id); + Assert.AreEqual(2, contentStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(0, totalItems); + Assert.AreEqual(0, children.Length); + } + + [Test] + public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginate() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths( + _contentByName["1-1"].Id, + _contentByName["1-3"].Id, + _contentByName["1-5"].Id, + _contentByName["1-7"].Id, + _contentByName["1-9"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1"].Key, + 0, + 2, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is 2 + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName["1-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["1-3"].Key, children[1].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + + // next result page + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1"].Key, + 2, + 2, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is still 2 + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName["1-5"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["1-7"].Key, children[1].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + + // next result page + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1"].Key, + 4, + 2, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + // page size is still 2, but this is the last result page + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName["1-9"].Key, children[0].Entity.Key); + Assert.IsTrue(children[0].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchildren_AsAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3-3"].Key, + 0, + 100, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(5, totalItems); + Assert.AreEqual(5, children.Length); + Assert.Multiple(() => + { + // all children of "3-3" should be allowed because "3-3" is a start node + foreach (var childNumber in Enumerable.Range(1, 5)) + { + var child = children[childNumber - 1]; + Assert.AreEqual(_contentByName[$"3-3-{childNumber}"].Key, child.Entity.Key); + Assert.IsTrue(child.HasAccess); + } + }); + } + + [Test] + public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildren_AsAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3-3"].Id, _contentByName["3-3-4"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3-3"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3-3" - that is, the actual start nodes + Assert.AreEqual(_contentByName["3-3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["3-3-4"].Key, children[1].Entity.Key); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsChildren_AsNotAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3-3"].Id, _contentByName["3-5-3"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3"].Key, + 0, + 3, + BySortOrder, + out var totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3" - that is, the parents of the actual start nodes + Assert.AreEqual(_contentByName["3-3"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["3-5"].Key, children[1].Entity.Key); + + // both are disallowed - only the two children (the actual start nodes) are allowed + Assert.IsFalse(children[0].HasAccess); + Assert.IsFalse(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild() + { + // NOTE: this test proves that start node paths are *not* additive when structural inheritance is in play. + // if one has a start node that is a descendant to another start node, the descendant start node "wins" + // and the ancestor start node is ignored. this differs somewhat from the norm; we normally consider + // permissions additive (which in this case would mean ignoring the descendant rather than the ancestor). + + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id, _contentByName["3-3-1"].Id, _contentByName["3-3-5"].Id); + Assert.AreEqual(2, contentStartNodePaths.Length); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + Assert.AreEqual(1, totalItems); + Assert.AreEqual(1, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName["3-3"].Key, children[0].Entity.Key); + Assert.IsFalse(children[0].HasAccess); + }); + + children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3-3"].Key, + 0, + 10, + BySortOrder, + out totalItems) + .ToArray(); + + Assert.AreEqual(2, totalItems); + Assert.AreEqual(2, children.Length); + Assert.Multiple(() => + { + // the two items are the children of "3-3" - that is, the actual start nodes + Assert.AreEqual(_contentByName["3-3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["3-3-5"].Key, children[1].Entity.Key); + + Assert.IsTrue(children[0].HasAccess); + Assert.IsTrue(children[1].HasAccess); + }); + } + + [Test] + public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectResultOrder() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id, _contentByName["3-2"].Id, _contentByName["3-1"].Id); + + var children = UserStartNodeEntitiesService + .ChildUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["3"].Key, + 0, + 10, + BySortOrder, + out var totalItems) + .ToArray(); + Assert.AreEqual(3, totalItems); + Assert.AreEqual(3, children.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName["3-1"].Key, children[0].Entity.Key); + Assert.AreEqual(_contentByName["3-2"].Key, children[1].Entity.Key); + Assert.AreEqual(_contentByName["3-3"].Key, children[2].Entity.Key); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs new file mode 100644 index 0000000000..c73ac2778b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs @@ -0,0 +1,85 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceTests +{ + [Test] + public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_contentByName["1"].Id, _contentByName["5"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodeIds) + .ToArray(); + + // expected total is 2, because only two items at root ("1" amd "10") are allowed + Assert.AreEqual(2, roots.Length); + Assert.Multiple(() => + { + // first and last content items are the ones allowed + Assert.AreEqual(_contentByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_contentByName["5"].Key, roots[1].Entity.Key); + + // explicitly verify the entity sort order, both so we know sorting works, + // and so we know it's actually the first and last item at root + Assert.AreEqual(0, roots[0].Entity.SortOrder); + Assert.AreEqual(4, roots[1].Entity.SortOrder); + + // both are allowed (they are the actual start nodes) + Assert.IsTrue(roots[0].HasAccess); + Assert.IsTrue(roots[1].HasAccess); + }); + } + + [Test] + public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_AsNotAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_contentByName["1-3"].Id, _contentByName["3-3"].Id, _contentByName["5-3"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodeIds) + .ToArray(); + + Assert.AreEqual(3, roots.Length); + Assert.Multiple(() => + { + // the three start nodes are the children of the "1", "3" and "5" roots, respectively, so these are expected as roots + Assert.AreEqual(_contentByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_contentByName["3"].Key, roots[1].Entity.Key); + Assert.AreEqual(_contentByName["5"].Key, roots[2].Entity.Key); + + // all are disallowed - only the children (the actual start nodes) are allowed + Assert.IsTrue(roots.All(r => r.HasAccess is false)); + }); + } + + [Test] + public async Task RootUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildRoots_AsNotAllowed() + { + var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_contentByName["1-2-3"].Id, _contentByName["2-3-4"].Id, _contentByName["3-4-5"].Id); + + var roots = UserStartNodeEntitiesService + .RootUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodeIds) + .ToArray(); + + Assert.AreEqual(3, roots.Length); + Assert.Multiple(() => + { + // the three start nodes are the grandchildren of the "1", "2" and "3" roots, respectively, so these are expected as roots + Assert.AreEqual(_contentByName["1"].Key, roots[0].Entity.Key); + Assert.AreEqual(_contentByName["2"].Key, roots[1].Entity.Key); + Assert.AreEqual(_contentByName["3"].Key, roots[2].Entity.Key); + + // all are disallowed - only the grandchildren (the actual start nodes) are allowed + Assert.IsTrue(roots.All(r => r.HasAccess is false)); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs new file mode 100644 index 0000000000..5cc6bff35d --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] +public partial class UserStartNodeEntitiesServiceTests : UmbracoIntegrationTest +{ + private Dictionary _contentByName = new (); + private IUserGroup _userGroup; + + private IContentService ContentService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IUserGroupService UserGroupService => GetRequiredService(); + + private IUserService UserService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + private IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService(); + + protected readonly Ordering BySortOrder = Ordering.By("sortOrder"); + + protected override void ConfigureTestServices(IServiceCollection services) + { + base.ConfigureTestServices(services); + services.AddTransient(); + } + + [SetUp] + public async Task SetUpTestAsync() + { + if (_contentByName.Any()) + { + return; + } + + var contentType = new ContentTypeBuilder() + .WithAlias("theContentType") + .Build(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }]; + await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + + foreach (var rootNumber in Enumerable.Range(1, 5)) + { + var root = new ContentBuilder() + .WithContentType(contentType) + .WithName($"{rootNumber}") + .Build(); + ContentService.Save(root); + _contentByName[root.Name!] = root; + + foreach (var childNumber in Enumerable.Range(1, 10)) + { + var child = new ContentBuilder() + .WithContentType(contentType) + .WithParent(root) + .WithName($"{rootNumber}-{childNumber}") + .Build(); + ContentService.Save(child); + _contentByName[child.Name!] = child; + + foreach (var grandChildNumber in Enumerable.Range(1, 5)) + { + var grandchild = new ContentBuilder() + .WithContentType(contentType) + .WithParent(child) + .WithName($"{rootNumber}-{childNumber}-{grandChildNumber}") + .Build(); + ContentService.Save(grandchild); + _contentByName[grandchild.Name!] = grandchild; + } + } + } + + _userGroup = new UserGroupBuilder() + .WithAlias("theGroup") + .WithAllowedSections(["content"]) + .Build(); + _userGroup.StartContentId = null; + await UserGroupService.CreateAsync(_userGroup, Constants.Security.SuperUserKey); + } + + private async Task CreateUserAndGetStartNodePaths(params int[] startNodeIds) + { + var user = await CreateUser(startNodeIds); + + var contentStartNodePaths = user.GetContentStartNodePaths(EntityService, AppCaches.NoCache); + Assert.IsNotNull(contentStartNodePaths); + + return contentStartNodePaths; + } + + private async Task CreateUserAndGetStartNodeIds(params int[] startNodeIds) + { + var user = await CreateUser(startNodeIds); + + var contentStartNodeIds = user.CalculateContentStartNodeIds(EntityService, AppCaches.NoCache); + Assert.IsNotNull(contentStartNodeIds); + + return contentStartNodeIds; + } + + private async Task CreateUser(int[] startNodeIds) + { + var user = new UserBuilder() + .WithName(Guid.NewGuid().ToString("N")) + .WithStartContentIds(startNodeIds) + .Build(); + UserService.Save(user); + + var attempt = await UserGroupService.AddUsersToUserGroupAsync( + new UsersToUserGroupManipulationModel(_userGroup.Key, [user.Key]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(attempt.Success); + return user; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 9efd93a4bc..db1e6d27e1 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -9,7 +9,7 @@ $(BaseEnablePackageValidation) $(NoWarn),NU5100 - + $(WarningsNotAsErrors),CS0108,SYSLIB0012,CS0618,SA1116,SA1117,CS0162,CS0169,SA1134,SA1405,CS4014,CS1998,CS0649,CS0168 - + @@ -253,5 +253,17 @@ PublishedUrlInfoProviderTestsBase.cs + + UserStartNodeEntitiesServiceTests.cs + + + UserStartNodeEntitiesServiceTests.cs + + + UserStartNodeEntitiesServiceMediaTests.cs + + + UserStartNodeEntitiesServiceMediaTests.cs + From bbfe40d733f1a175f0caf9d8419f3ed79eb2e920 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 3 Apr 2025 06:45:14 +0200 Subject: [PATCH 04/53] Fix issue preventing blueprint derived values from being scaffolded (#18917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix issue preventing blueprint derived values from being scaffolded. * fix manipulating frooen array * compare with variantId as well --------- Co-authored-by: Niels Lyngsø --- .../content-detail-workspace-base.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/content-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/content-detail-workspace-base.ts index da8ccb7b14..562b3c467a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/content-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/content/workspace/content-detail-workspace-base.ts @@ -283,7 +283,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< await this.structure.loadType((data as any)[this.#contentTypePropertyName].unique); // Set culture and segment for all values: - const cutlures = this.#languages.getValue().map((x) => x.unique); + const cultures = this.#languages.getValue().map((x) => x.unique); if (this.structure.variesBySegment) { console.warn('Segments are not yet implemented for preset'); @@ -319,11 +319,28 @@ export abstract class UmbContentDetailWorkspaceContextBase< ); const controller = new UmbPropertyValuePresetVariantBuilderController(this); - controller.setCultures(cutlures); + controller.setCultures(cultures); if (segments) { controller.setSegments(segments); } - data.values = await controller.create(valueDefinitions); + + const presetValues = await controller.create(valueDefinitions); + + // Don't just set the values, as we could have some already populated from a blueprint. + // If we have a value from both a blueprint and a preset, use the latter as priority. + const dataValues = [...data.values]; + for (let index = 0; index < presetValues.length; index++) { + const presetValue = presetValues[index]; + const variantId = UmbVariantId.Create(presetValue); + const matchingDataValueIndex = dataValues.findIndex((v) => v.alias === presetValue.alias && variantId.compare(v)); + if (matchingDataValueIndex > -1) { + dataValues[matchingDataValueIndex] = presetValue; + } else { + dataValues.push(presetValue); + } + } + + data.values = dataValues; return data; } From 3b736d8c73bc7bc74b15cba52119a2044327a48a Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 3 Apr 2025 09:01:41 +0200 Subject: [PATCH 05/53] ci: add Azure Static Web Apps workflow file on-behalf-of: @Azure opensource@microsoft.com --- ...-static-web-apps-yellow-bush-073fc4d10.yml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/azure-static-web-apps-yellow-bush-073fc4d10.yml diff --git a/.github/workflows/azure-static-web-apps-yellow-bush-073fc4d10.yml b/.github/workflows/azure-static-web-apps-yellow-bush-073fc4d10.yml new file mode 100644 index 0000000000..3f8fb138f1 --- /dev/null +++ b/.github/workflows/azure-static-web-apps-yellow-bush-073fc4d10.yml @@ -0,0 +1,46 @@ +name: Azure Static Web Apps CI/CD + +on: + push: + branches: + - contrib + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - contrib + +jobs: + build_and_deploy_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Build and Deploy Job + steps: + - uses: actions/checkout@v3 + with: + submodules: true + lfs: false + - name: Build And Deploy + id: builddeploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_YELLOW_BUSH_073FC4D10 }} + repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) + action: "upload" + ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### + # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig + app_location: "/" # App source code path + api_location: "" # Api source code path - optional + output_location: "" # Built app content directory - optional + ###### End of Repository/Build Configurations ###### + + close_pull_request_job: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + name: Close Pull Request Job + steps: + - name: Close Pull Request + id: closepullrequest + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_YELLOW_BUSH_073FC4D10 }} + action: "close" From 3cd6fcfe67d1dad1eb728d8a7b885db4ca02593d Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 3 Apr 2025 09:03:45 +0200 Subject: [PATCH 06/53] ci: add Azure Static Web Apps workflow file on-behalf-of: @Azure opensource@microsoft.com --- ...c-web-apps-victorious-ground-017b08103.yml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/azure-static-web-apps-victorious-ground-017b08103.yml diff --git a/.github/workflows/azure-static-web-apps-victorious-ground-017b08103.yml b/.github/workflows/azure-static-web-apps-victorious-ground-017b08103.yml new file mode 100644 index 0000000000..1ce226e036 --- /dev/null +++ b/.github/workflows/azure-static-web-apps-victorious-ground-017b08103.yml @@ -0,0 +1,46 @@ +name: Azure Static Web Apps CI/CD + +on: + push: + branches: + - contrib + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - contrib + +jobs: + build_and_deploy_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Build and Deploy Job + steps: + - uses: actions/checkout@v3 + with: + submodules: true + lfs: false + - name: Build And Deploy + id: builddeploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_VICTORIOUS_GROUND_017B08103 }} + repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) + action: "upload" + ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### + # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig + app_location: "/" # App source code path + api_location: "" # Api source code path - optional + output_location: "" # Built app content directory - optional + ###### End of Repository/Build Configurations ###### + + close_pull_request_job: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + name: Close Pull Request Job + steps: + - name: Close Pull Request + id: closepullrequest + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_VICTORIOUS_GROUND_017B08103 }} + action: "close" From 9db62a2c0c099075baec3ce93d0f54dbc4a2c2fa Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 3 Apr 2025 09:07:16 +0200 Subject: [PATCH 07/53] ci: add Azure Static Web Apps workflow file on-behalf-of: @Azure opensource@microsoft.com --- ...e-static-web-apps-orange-sea-0c7411a03.yml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/azure-static-web-apps-orange-sea-0c7411a03.yml diff --git a/.github/workflows/azure-static-web-apps-orange-sea-0c7411a03.yml b/.github/workflows/azure-static-web-apps-orange-sea-0c7411a03.yml new file mode 100644 index 0000000000..8f1351f93f --- /dev/null +++ b/.github/workflows/azure-static-web-apps-orange-sea-0c7411a03.yml @@ -0,0 +1,46 @@ +name: Azure Static Web Apps CI/CD + +on: + push: + branches: + - contrib + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - contrib + +jobs: + build_and_deploy_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Build and Deploy Job + steps: + - uses: actions/checkout@v3 + with: + submodules: true + lfs: false + - name: Build And Deploy + id: builddeploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_ORANGE_SEA_0C7411A03 }} + repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) + action: "upload" + ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### + # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig + app_location: "/" # App source code path + api_location: "" # Api source code path - optional + output_location: "" # Built app content directory - optional + ###### End of Repository/Build Configurations ###### + + close_pull_request_job: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + name: Close Pull Request Job + steps: + - name: Close Pull Request + id: closepullrequest + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_ORANGE_SEA_0C7411A03 }} + action: "close" From e4b3104399fcd2560fa6d6cb04ee39a6f652ea26 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 3 Apr 2025 10:51:45 +0200 Subject: [PATCH 08/53] Remove admin permission on user configuration, allowing users with user section access only to manaage users and groups. (#18848) --- .../Controllers/User/ConfigurationUserController.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ConfigurationUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ConfigurationUserController.cs index 7c9723c744..915158a92c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/ConfigurationUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ConfigurationUserController.cs @@ -1,15 +1,12 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Authorization; +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.User; -using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.User; [ApiVersion("1.0")] -[Authorize(Policy = AuthorizationPolicies.RequireAdminAccess)] public class ConfigurationUserController : UserControllerBase { private readonly IUserPresentationFactory _userPresentationFactory; From f3658bf356c790ca34b33cfaf2e66367c880d65f Mon Sep 17 00:00:00 2001 From: Lee Kelleher Date: Thu, 3 Apr 2025 10:44:57 +0100 Subject: [PATCH 09/53] Tiptap RTE: Style Menu extension kind (#18918) * Adds 'styleMenu' Tiptap toolbar extension kind * Adds icons for `

` and `

` tags * Adds commands to HTML Global Attributes extension for setting the `class` and `id` attributes. * Renamed "default-tiptap-toolbar-element.api.ts" file The "element" part was confusing. * Toolbar Menu: uses correct `item` value * Cascading Menu: adds localization for the label * Adds `label` attribute to UUI components for accessibility. * Toolbar Menu: uses correct `appearance` value * Removed unrequired `api` from Style Select * Destructs the `item.data` object --- .../tiptap/extensions/tiptap-div.extension.ts | 4 +- ...tiptap-html-global-attributes.extension.ts | 44 +++++++++++++ .../extensions/tiptap-span.extension.ts | 2 +- .../core/icon-registry/icon-dictionary.json | 8 +++ .../src/packages/core/icon-registry/icons.ts | 6 ++ .../icon-registry/icons/icon-heading-4.ts | 1 + .../icon-registry/icons/icon-paragraph.ts | 1 + .../cascading-menu-popover.element.ts | 8 ++- .../input-tiptap/tiptap-toolbar.element.ts | 2 +- ...t.api.ts => default-tiptap-toolbar-api.ts} | 0 .../toolbar/style-menu.tiptap-toolbar-api.ts | 34 ++++++++++ ...tap-toolbar-color-picker-button.element.ts | 2 +- .../toolbar/tiptap-toolbar-menu.element.ts | 9 +-- .../packages/tiptap/extensions/manifests.ts | 46 ++++++++----- .../extensions/style-select/manifests.ts | 65 ++++++++++++------- .../style-select.tiptap-toolbar-api.ts | 22 +------ .../extensions/tiptap-toolbar.extension.ts | 30 ++++++--- 17 files changed, 205 insertions(+), 79 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-4.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-paragraph.ts rename src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/{default-tiptap-toolbar-element.api.ts => default-tiptap-toolbar-api.ts} (100%) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/style-menu.tiptap-toolbar-api.ts diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts index 86563b88c6..e7855d4288 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts @@ -23,10 +23,10 @@ export const Div = Node.create({ }, parseHTML() { - return [{ tag: 'div' }]; + return [{ tag: this.name }]; }, renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + return [this.name, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; }, }); diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts index b86b6ecc31..011a8209c6 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts @@ -52,4 +52,48 @@ export const HtmlGlobalAttributes = Extension.create + ({ commands }) => { + if (!className) return false; + const types = type ? [type] : this.options.types; + return types + .map((type) => commands.updateAttributes(type, { class: className })) + .every((response) => response); + }, + unsetClassName: + (type) => + ({ commands }) => { + const types = type ? [type] : this.options.types; + return types.map((type) => commands.resetAttributes(type, 'class')).every((response) => response); + }, + setId: + (id, type) => + ({ commands }) => { + if (!id) return false; + const types = type ? [type] : this.options.types; + return types.map((type) => commands.updateAttributes(type, { id })).every((response) => response); + }, + unsetId: + (type) => + ({ commands }) => { + const types = type ? [type] : this.options.types; + return types.map((type) => commands.resetAttributes(type, 'id')).every((response) => response); + }, + }; + }, }); + +declare module '@tiptap/core' { + interface Commands { + htmlGlobalAttributes: { + setClassName: (className?: string, type?: string) => ReturnType; + unsetClassName: (type?: string) => ReturnType; + setId: (id?: string, type?: string) => ReturnType; + unsetId: (type?: string) => ReturnType; + }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts index a4a375f100..6d49c46461 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts @@ -28,7 +28,7 @@ export const Span = Mark.create({ return { setSpanStyle: (styles) => - ({ commands, editor, chain }) => { + ({ commands, editor }) => { if (!styles) return false; const existing = editor.getAttributes(this.name)?.style as string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json index d4badf6fb5..5675562abc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json @@ -986,6 +986,10 @@ "name": "icon-heading-3", "file": "heading-3.svg" }, + { + "name": "icon-heading-4", + "file": "heading-4.svg" + }, { "name": "icon-headphones", "file": "headphones.svg" @@ -1507,6 +1511,10 @@ "name": "icon-partly-cloudy", "file": "cloud-sun.svg" }, + { + "name": "icon-paragraph", + "file": "pilcrow.svg" + }, { "name": "icon-paste-in", "file": "clipboard-paste.svg", diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts index 03eb3c6d1b..6efeae5314 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts @@ -775,6 +775,9 @@ path: () => import("./icons/icon-heading-2.js"), name: "icon-heading-3", path: () => import("./icons/icon-heading-3.js"), },{ +name: "icon-heading-4", +path: () => import("./icons/icon-heading-4.js"), +},{ name: "icon-headphones", path: () => import("./icons/icon-headphones.js"), },{ @@ -1217,6 +1220,9 @@ path: () => import("./icons/icon-paper-plane.js"), name: "icon-partly-cloudy", path: () => import("./icons/icon-partly-cloudy.js"), },{ +name: "icon-paragraph", +path: () => import("./icons/icon-paragraph.js"), +},{ name: "icon-paste-in", legacy: true, hidden: true, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-4.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-4.ts new file mode 100644 index 0000000000..765f9c988a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-4.ts @@ -0,0 +1 @@ +export default ``; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-paragraph.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-paragraph.ts new file mode 100644 index 0000000000..e22a3a37e7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-paragraph.ts @@ -0,0 +1 @@ +export default ``; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts index 513abc2ffc..047b6c0589 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts @@ -1,4 +1,5 @@ import { css, customElement, html, ifDefined, property, repeat, when } from '@umbraco-cms/backoffice/external/lit'; +import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; import { UUIPopoverContainerElement } from '@umbraco-cms/backoffice/external/uui'; export type UmbCascadingMenuItem = { @@ -12,7 +13,7 @@ export type UmbCascadingMenuItem = { }; @customElement('umb-cascading-menu-popover') -export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { +export class UmbCascadingMenuPopoverElement extends UmbElementMixin(UUIPopoverContainerElement) { @property({ type: Array }) items?: Array; @@ -70,6 +71,8 @@ export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { element.setAttribute('popovertarget', popoverId); } + const label = this.localize.string(item.label); + return html`

this.#onMouseEnter(item, popoverId)} @@ -80,11 +83,12 @@ export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { () => html` this.#onClick(item, popoverId)}> ${when(item.icon, (icon) => html``)} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts index 33fc3116cf..44ffd238b2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts @@ -61,7 +61,7 @@ export class UmbTiptapToolbarElement extends UmbLitElement { }, undefined, undefined, - () => import('../toolbar/default-tiptap-toolbar-element.api.js'), + () => import('../toolbar/default-tiptap-toolbar-api.js'), ); this.#extensionsController.apiProperties = { configuration: this.configuration }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-element.api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-api.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-element.api.ts rename to src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-api.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/style-menu.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/style-menu.tiptap-toolbar-api.ts new file mode 100644 index 0000000000..797c382aec --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/style-menu.tiptap-toolbar-api.ts @@ -0,0 +1,34 @@ +import { UmbTiptapToolbarElementApiBase } from '../../extensions/base.js'; +import type { MetaTiptapToolbarStyleMenuItem } from '../../extensions/types.js'; +import type { ChainedCommands, Editor } from '@umbraco-cms/backoffice/external/tiptap'; + +export default class UmbTiptapToolbarStyleMenuApi extends UmbTiptapToolbarElementApiBase { + #commands: Record ChainedCommands }> = { + h1: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 1 }) }, + h2: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 2 }) }, + h3: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 3 }) }, + h4: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 4 }) }, + h5: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 5 }) }, + h6: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 6 }) }, + p: { type: 'paragraph', command: (chain) => chain.setParagraph() }, + blockquote: { type: 'blockquote', command: (chain) => chain.toggleBlockquote() }, + code: { type: 'code', command: (chain) => chain.toggleCode() }, + codeBlock: { type: 'codeBlock', command: (chain) => chain.toggleCodeBlock() }, + div: { type: 'div', command: (chain) => chain.toggleNode('div', 'paragraph') }, + em: { type: 'italic', command: (chain) => chain.setItalic() }, + ol: { type: 'orderedList', command: (chain) => chain.toggleOrderedList() }, + strong: { type: 'bold', command: (chain) => chain.setBold() }, + s: { type: 'strike', command: (chain) => chain.setStrike() }, + span: { type: 'span', command: (chain) => chain.toggleMark('span') }, + u: { type: 'underline', command: (chain) => chain.setUnderline() }, + ul: { type: 'bulletList', command: (chain) => chain.toggleBulletList() }, + }; + + override execute(editor?: Editor, item?: MetaTiptapToolbarStyleMenuItem) { + if (!editor || !item?.data) return; + const { tag, id, class: className } = item.data; + const focus = editor.chain().focus(); + const ext = tag ? this.#commands[tag] : null; + (ext?.command?.(focus) ?? focus).setId(id, ext?.type).setClassName(className, ext?.type).run(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-color-picker-button.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-color-picker-button.element.ts index ff19362aec..07b173accc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-color-picker-button.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-color-picker-button.element.ts @@ -38,7 +38,7 @@ export class UmbTiptapToolbarColorPickerButtonElement extends UmbTiptapToolbarBu - + diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-menu.element.ts index 9576445ecf..7f7726eb10 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-menu.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-menu.element.ts @@ -49,8 +49,9 @@ export class UmbTiptapToolbarMenuElement extends UmbLitElement { } async #setMenu() { - if (!this.#manifest?.meta.items) return; - this.#menu = await this.#getMenuItems(this.#manifest.meta.items); + const items = this.#manifest?.items ?? this.#manifest?.meta.items; + if (!items) return; + this.#menu = await this.#getMenuItems(items); } async #getMenuItems(items: Array): Promise> { @@ -92,10 +93,10 @@ export class UmbTiptapToolbarMenuElement extends UmbLitElement { } return { - icon: item.icon, + icon: item.appearance?.icon ?? item.icon, items, label: item.label, - style: item.style, + style: item.appearance?.style ?? item.style, separatorAfter: item.separatorAfter, element, execute: () => this.api?.execute(this.editor, item), diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts index cb3b5c79ea..06305ea6bd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts @@ -33,6 +33,16 @@ const kinds: Array = [ element: () => import('../components/toolbar/tiptap-toolbar-menu.element.js'), }, }, + { + type: 'kind', + alias: 'Umb.Kind.TiptapToolbar.StyleMenu', + matchKind: 'styleMenu', + matchType: 'tiptapToolbarExtension', + manifest: { + api: () => import('../components/toolbar/style-menu.tiptap-toolbar-api.js'), + element: () => import('../components/toolbar/tiptap-toolbar-menu.element.js'), + }, + }, ]; const coreExtensions: Array = [ @@ -581,17 +591,17 @@ const toolbarExtensions: Array = [ alias: 'Umb.Tiptap.Toolbar.FontFamily', name: 'Font Family Tiptap Extension', api: () => import('./toolbar/font-family.tiptap-toolbar-api.js'), + items: [ + { label: 'Sans serif', appearance: { style: 'font-family: sans-serif;' }, data: 'sans-serif' }, + { label: 'Serif', appearance: { style: 'font-family: serif;' }, data: 'serif' }, + { label: 'Monospace', appearance: { style: 'font-family: monospace;' }, data: 'monospace' }, + { label: 'Cursive', appearance: { style: 'font-family: cursive;' }, data: 'cursive' }, + { label: 'Fantasy', appearance: { style: 'font-family: fantasy;' }, data: 'fantasy' }, + ], meta: { alias: 'umbFontFamily', icon: 'icon-ruler-alt', label: 'Font family', - items: [ - { label: 'Sans serif', style: 'font-family: sans-serif;', data: 'sans-serif' }, - { label: 'Serif', style: 'font-family: serif;', data: 'serif' }, - { label: 'Monospace', style: 'font-family: monospace;', data: 'monospace' }, - { label: 'Cursive', style: 'font-family: cursive;', data: 'cursive' }, - { label: 'Fantasy', style: 'font-family: fantasy;', data: 'fantasy' }, - ], }, }, { @@ -600,21 +610,21 @@ const toolbarExtensions: Array = [ alias: 'Umb.Tiptap.Toolbar.FontSize', name: 'Font Size Tiptap Extension', api: () => import('./toolbar/font-size.tiptap-toolbar-api.js'), + items: [ + { label: '8pt', data: '8pt;' }, + { label: '10pt', data: '10pt;' }, + { label: '12pt', data: '12pt;' }, + { label: '14pt', data: '14pt;' }, + { label: '16pt', data: '16pt;' }, + { label: '18pt', data: '18pt;' }, + { label: '24pt', data: '24pt;' }, + { label: '26pt', data: '26pt;' }, + { label: '48pt', data: '48pt;' }, + ], meta: { alias: 'umbFontSize', icon: 'icon-ruler', label: 'Font size', - items: [ - { label: '8pt', data: '8pt;' }, - { label: '10pt', data: '10pt;' }, - { label: '12pt', data: '12pt;' }, - { label: '14pt', data: '14pt;' }, - { label: '16pt', data: '16pt;' }, - { label: '18pt', data: '18pt;' }, - { label: '24pt', data: '24pt;' }, - { label: '26pt', data: '26pt;' }, - { label: '48pt', data: '48pt;' }, - ], }, }, { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts index 915c7c939e..60c0894e84 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts @@ -1,35 +1,54 @@ export const manifests: Array = [ { type: 'tiptapToolbarExtension', - kind: 'menu', + kind: 'styleMenu', alias: 'Umb.Tiptap.Toolbar.StyleSelect', name: 'Style Select Tiptap Extension', - api: () => import('./style-select.tiptap-toolbar-api.js'), + items: [ + { + label: 'Headers', + items: [ + { + label: 'Page header', + appearance: { icon: 'icon-heading-2', style: 'font-size: x-large;font-weight: bold;' }, + data: { tag: 'h2' }, + }, + { + label: 'Section header', + appearance: { icon: 'icon-heading-3', style: 'font-size: large;font-weight: bold;' }, + data: { tag: 'h3' }, + }, + { + label: 'Paragraph header', + appearance: { icon: 'icon-heading-4', style: 'font-weight: bold;' }, + data: { tag: 'h4' }, + }, + ], + }, + { + label: 'Blocks', + items: [{ label: 'Paragraph', appearance: { icon: 'icon-paragraph' }, data: { tag: 'p' } }], + }, + { + label: 'Containers', + items: [ + { + label: 'Block quote', + appearance: { icon: 'icon-blockquote', style: 'font-style: italic;' }, + data: { tag: 'blockquote' }, + }, + { + label: 'Code block', + appearance: { icon: 'icon-code', style: 'font-family: monospace;' }, + data: { tag: 'codeBlock' }, + }, + ], + }, + ], meta: { alias: 'umbStyleSelect', icon: 'icon-palette', label: 'Style Select', - items: [ - { - label: 'Headers', - items: [ - { label: 'Page header', data: 'h2', style: 'font-size: x-large;font-weight: bold;' }, - { label: 'Section header', data: 'h3', style: 'font-size: large;font-weight: bold;' }, - { label: 'Paragraph header', data: 'h4', style: 'font-weight: bold;' }, - ], - }, - { - label: 'Blocks', - items: [{ label: 'Paragraph', data: 'p' }], - }, - { - label: 'Containers', - items: [ - { label: 'Quote', data: 'blockquote', style: 'font-style: italic;' }, - { label: 'Code', data: 'codeBlock', style: 'font-family: monospace;' }, - ], - }, - ], }, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select.tiptap-toolbar-api.ts index 3c65eed2f8..d67746a412 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select.tiptap-toolbar-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select.tiptap-toolbar-api.ts @@ -1,20 +1,4 @@ -import { UmbTiptapToolbarElementApiBase } from '../base.js'; -import type { MetaTiptapToolbarMenuItem } from '../types.js'; -import type { Editor } from '@umbraco-cms/backoffice/external/tiptap'; +import UmbTiptapToolbarStyleMenuApi from '../../components/toolbar/style-menu.tiptap-toolbar-api.js'; -export default class UmbTiptapToolbarStyleSelectExtensionApi extends UmbTiptapToolbarElementApiBase { - #commands: Record void> = { - h2: (editor) => editor?.chain().focus().toggleHeading({ level: 2 }).run(), - h3: (editor) => editor?.chain().focus().toggleHeading({ level: 3 }).run(), - h4: (editor) => editor?.chain().focus().toggleHeading({ level: 4 }).run(), - p: (editor) => editor?.chain().focus().setParagraph().run(), - blockquote: (editor) => editor?.chain().focus().toggleBlockquote().run(), - codeBlock: (editor) => editor?.chain().focus().toggleCodeBlock().run(), - }; - - override execute(editor?: Editor, item?: MetaTiptapToolbarMenuItem) { - if (!item?.data) return; - const key = item.data.toString(); - this.#commands[key](editor); - } -} +/** @deprecated No longer used internally. This class will be removed in Umbraco 17. [LK] */ +export default class UmbTiptapToolbarStyleSelectExtensionApi extends UmbTiptapToolbarStyleMenuApi {} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap-toolbar.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap-toolbar.extension.ts index 35432bca51..9370648134 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap-toolbar.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap-toolbar.extension.ts @@ -30,27 +30,40 @@ export interface ManifestTiptapToolbarExtensionColorPickerButtonKind< kind: 'colorPickerButton'; } -export interface MetaTiptapToolbarMenuItem { - data?: unknown; +export interface MetaTiptapToolbarMenuItem { + appearance?: { icon?: string; style?: string }; + data?: ItemDataType; element?: ElementLoaderProperty; elementName?: string; + /** @deprecated No longer used, please use `appearance: { icon }`. This will be removed in Umbraco 17. [LK] */ icon?: string; - items?: Array; + items?: Array>; label: string; separatorAfter?: boolean; + /** @deprecated No longer used, please use `appearance: { style }`. This will be removed in Umbraco 17. [LK] */ style?: string; } export interface MetaTiptapToolbarMenuExtension extends MetaTiptapToolbarExtension { look?: 'icon' | 'text'; - items: Array; + /** @deprecated No longer used, please use `items` at the root manifest. This will be removed in Umbraco 17. [LK] */ + items?: Array; } -export interface ManifestTiptapToolbarExtensionMenuKind< - MetaType extends MetaTiptapToolbarMenuExtension = MetaTiptapToolbarMenuExtension, -> extends ManifestTiptapToolbarExtension { +export interface ManifestTiptapToolbarExtensionMenuKind + extends ManifestTiptapToolbarExtension { type: 'tiptapToolbarExtension'; kind: 'menu'; + items?: Array; +} + +export type MetaTiptapToolbarStyleMenuItem = MetaTiptapToolbarMenuItem<{ tag?: string; class?: string; id?: string }>; + +export interface ManifestTiptapToolbarExtensionStyleMenuKind + extends ManifestTiptapToolbarExtension { + type: 'tiptapToolbarExtension'; + kind: 'styleMenu'; + items: Array; } declare global { @@ -59,6 +72,7 @@ declare global { | ManifestTiptapToolbarExtension | ManifestTiptapToolbarExtensionButtonKind | ManifestTiptapToolbarExtensionColorPickerButtonKind - | ManifestTiptapToolbarExtensionMenuKind; + | ManifestTiptapToolbarExtensionMenuKind + | ManifestTiptapToolbarExtensionStyleMenuKind; } } From 7d4179154348fbbd156302ff5f59bbc92e91cd0f Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 3 Apr 2025 21:55:34 +0200 Subject: [PATCH 10/53] Ensure has children reflects only items with folder children when folders only are queried. (#18790) * Ensure has children reflects only items with folder children when folders only are queried. * Added supression for change to integration test public code. --------- Co-authored-by: Migaroez --- .../Implement/EntityRepository.cs | 23 +++-- .../CompatibilitySuppressions.xml | 7 ++ .../Services/EntityServiceTests.cs | 88 +++++++++++++++++-- 3 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index fdaadb3027..8ff1fe381b 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -452,10 +452,12 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended return AddGroupBy(isContent, isMedia, isMember, sql, true); } + protected Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, bool isCount = false) + => GetBase(isContent, isMedia, isMember, filter, [], isCount); + // gets the base SELECT + FROM [+ filter] sql // always from the 'current' content version - protected Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, - bool isCount = false) + protected Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, Guid[] objectTypes, bool isCount = false) { Sql sql = Sql(); @@ -469,8 +471,19 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended .Select(x => x.NodeId, x => x.Trashed, x => x.ParentId, x => x.UserId, x => x.Level, x => x.Path) .AndSelect(x => x.SortOrder, x => x.UniqueId, x => x.Text, x => x.NodeObjectType, - x => x.CreateDate) - .Append(", COUNT(child.id) AS children"); + x => x.CreateDate); + + if (objectTypes.Length == 0) + { + sql.Append(", COUNT(child.id) AS children"); + } + else + { + // The following is safe from SQL injection as we are dealing with GUIDs, not strings. + // Upper-case is necessary for SQLite, and also works for SQL Server. + var objectTypesForInClause = string.Join("','", objectTypes.Select(x => x.ToString().ToUpperInvariant())); + sql.Append($", SUM(CASE WHEN child.nodeObjectType IN ('{objectTypesForInClause}') THEN 1 ELSE 0 END) AS children"); + } if (isContent || isMedia || isMember) { @@ -545,7 +558,7 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, Action>? filter, Guid[] objectTypes) { - Sql sql = GetBase(isContent, isMedia, isMember, filter, isCount); + Sql sql = GetBase(isContent, isMedia, isMember, filter, objectTypes, isCount); if (objectTypes.Length > 0) { sql.WhereIn(x => x.NodeObjectType, objectTypes); diff --git a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml index 9a446cdb11..880504a1d4 100644 --- a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml +++ b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml @@ -106,6 +106,13 @@ lib/net9.0/Umbraco.Tests.Integration.dll true + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.EntityServiceTests.CreateTestData + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + CP0002 M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.TemplateServiceTests.Deleting_Master_Template_Also_Deletes_Children diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs index d18caf52b4..20df8e690a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs @@ -1,19 +1,18 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.ContentTypeEditing; using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; using Umbraco.Cms.Tests.Common.Attributes; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; -using Umbraco.Extensions; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; @@ -35,7 +34,7 @@ public class EntityServiceTests : UmbracoIntegrationTest await LanguageService.CreateAsync(_langEs, Constants.Security.SuperUserKey); } - CreateTestData(); + await CreateTestData(); } private Language? _langFr; @@ -57,6 +56,10 @@ public class EntityServiceTests : UmbracoIntegrationTest private IFileService FileService => GetRequiredService(); + private IContentTypeContainerService ContentTypeContainerService => GetRequiredService(); + + public IContentTypeEditingService ContentTypeEditingService => GetRequiredService(); + [Test] public void EntityService_Can_Get_Paged_Descendants_Ordering_Path() { @@ -381,6 +384,43 @@ public class EntityServiceTests : UmbracoIntegrationTest Assert.That(total, Is.EqualTo(10)); } + [Test] + public async Task EntityService_Can_Get_Paged_Document_Type_Children() + { + IEnumerable children = EntityService.GetPagedChildren( + _documentTypeRootContainerKey, + [UmbracoObjectTypes.DocumentTypeContainer], + [UmbracoObjectTypes.DocumentTypeContainer, UmbracoObjectTypes.DocumentType], + 0, + 10, + false, + out long totalRecords); + + Assert.AreEqual(3, totalRecords); + Assert.AreEqual(3, children.Count()); + Assert.IsTrue(children.Single(x => x.Key == _documentTypeSubContainer1Key).HasChildren); // Has a single folder as a child. + Assert.IsTrue(children.Single(x => x.Key == _documentTypeSubContainer2Key).HasChildren); // Has a single document type as a child. + Assert.IsFalse(children.Single(x => x.Key == _documentType1Key).HasChildren); // Is a document type (has no children). + } + + [Test] + public async Task EntityService_Can_Get_Paged_Document_Type_Children_For_Folders_Only() + { + IEnumerable children = EntityService.GetPagedChildren( + _documentTypeRootContainerKey, + [UmbracoObjectTypes.DocumentTypeContainer], + [UmbracoObjectTypes.DocumentTypeContainer], + 0, + 10, + false, + out long totalRecords); + + Assert.AreEqual(2, totalRecords); + Assert.AreEqual(2, children.Count()); + Assert.IsTrue(children.Single(x => x.Key == _documentTypeSubContainer1Key).HasChildren); // Has a single folder as a child. + Assert.IsFalse(children.Single(x => x.Key == _documentTypeSubContainer2Key).HasChildren); // Has a single document type as a child. + } + [Test] [LongRunning] public void EntityService_Can_Get_Paged_Media_Descendants() @@ -738,7 +778,7 @@ public class EntityServiceTests : UmbracoIntegrationTest var entities = EntityService.GetAll(UmbracoObjectTypes.DocumentType).ToArray(); Assert.That(entities.Any(), Is.True); - Assert.That(entities.Count(), Is.EqualTo(1)); + Assert.That(entities.Count(), Is.EqualTo(3)); } [Test] @@ -748,7 +788,7 @@ public class EntityServiceTests : UmbracoIntegrationTest var entities = EntityService.GetAll(objectTypeId).ToArray(); Assert.That(entities.Any(), Is.True); - Assert.That(entities.Count(), Is.EqualTo(1)); + Assert.That(entities.Count(), Is.EqualTo(3)); } [Test] @@ -757,7 +797,7 @@ public class EntityServiceTests : UmbracoIntegrationTest var entities = EntityService.GetAll().ToArray(); Assert.That(entities.Any(), Is.True); - Assert.That(entities.Count(), Is.EqualTo(1)); + Assert.That(entities.Count(), Is.EqualTo(3)); } [Test] @@ -885,7 +925,7 @@ public class EntityServiceTests : UmbracoIntegrationTest private Media _subfolder; private Media _subfolder2; - public void CreateTestData() + public async Task CreateTestData() { if (_isSetup == false) { @@ -942,6 +982,38 @@ public class EntityServiceTests : UmbracoIntegrationTest // Create and save sub folder -> 1061 _subfolder2 = MediaBuilder.CreateMediaFolder(_folderMediaType, _subfolder.Id); MediaService.Save(_subfolder2, -1); + + // Setup document type folder structure for tests on paged children with or without folders + await CreateStructureForPagedDocumentTypeChildrenTest(); } } + + private static readonly Guid _documentTypeRootContainerKey = Guid.NewGuid(); + private static readonly Guid _documentTypeSubContainer1Key = Guid.NewGuid(); + private static readonly Guid _documentTypeSubContainer2Key = Guid.NewGuid(); + private static readonly Guid _documentType1Key = Guid.NewGuid(); + + private async Task CreateStructureForPagedDocumentTypeChildrenTest() + { + // Structure created: + // - root container + // - sub container 1 + // - sub container 1b + // - sub container 2 + // - doc type 2 + // - doc type 1 + await ContentTypeContainerService.CreateAsync(_documentTypeRootContainerKey, "Root Container", null, Constants.Security.SuperUserKey); + await ContentTypeContainerService.CreateAsync(_documentTypeSubContainer1Key, "Sub Container 1", _documentTypeRootContainerKey, Constants.Security.SuperUserKey); + await ContentTypeContainerService.CreateAsync(_documentTypeSubContainer2Key, "Sub Container 2", _documentTypeRootContainerKey, Constants.Security.SuperUserKey); + await ContentTypeContainerService.CreateAsync(Guid.NewGuid(), "Sub Container 1b", _documentTypeSubContainer1Key, Constants.Security.SuperUserKey); + + var docType1Model = ContentTypeEditingBuilder.CreateBasicContentType("umbDocType1", "Doc Type 1"); + docType1Model.ContainerKey = _documentTypeRootContainerKey; + docType1Model.Key = _documentType1Key; + await ContentTypeEditingService.CreateAsync(docType1Model, Constants.Security.SuperUserKey); + + var docType2Model = ContentTypeEditingBuilder.CreateBasicContentType("umbDocType2", "Doc Type 2"); + docType2Model.ContainerKey = _documentTypeSubContainer2Key; + await ContentTypeEditingService.CreateAsync(docType2Model, Constants.Security.SuperUserKey); + } } From 3e6b9313e5600005c64ee11b6c193906e4d92106 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 3 Apr 2025 22:09:40 +0200 Subject: [PATCH 11/53] Only apply validation on content update to variant cultures where the editor has permission for the culture (#18778) * Only apply validation on content update to variant cultures where the editor has permission for the culture. * Remove inadvertent comment updates. * Fixed failing integration test. --- .../ValidateCreateDocumentController.cs | 27 ++- .../ValidateUpdateDocumentController.cs | 28 ++- .../Services/ContentEditingService.cs | 63 ++++-- .../Services/IContentEditingService.cs | 16 +- .../Builders/UserGroupBuilder.cs | 45 ++++- .../ContentEditingServiceTests.Update.cs | 5 +- .../ContentEditingServiceTests.Validate.cs | 191 ++++++++++++++++++ .../ContentEditingServiceTestsBase.cs | 64 +++--- .../Umbraco.Tests.Integration.csproj | 5 +- 9 files changed, 389 insertions(+), 55 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Validate.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs index ecca82286c..7087e119f7 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs @@ -1,11 +1,14 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; @@ -16,15 +19,32 @@ public class ValidateCreateDocumentController : CreateDocumentControllerBase { private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; private readonly IContentEditingService _contentEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")] public ValidateCreateDocumentController( IAuthorizationService authorizationService, IDocumentEditingPresentationFactory documentEditingPresentationFactory, IContentEditingService contentEditingService) + : this( + authorizationService, + documentEditingPresentationFactory, + contentEditingService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [ActivatorUtilitiesConstructor] + public ValidateCreateDocumentController( + IAuthorizationService authorizationService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory, + IContentEditingService contentEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : base(authorizationService) { _documentEditingPresentationFactory = documentEditingPresentationFactory; _contentEditingService = contentEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } [HttpPost("validate")] @@ -36,7 +56,10 @@ public class ValidateCreateDocumentController : CreateDocumentControllerBase => await HandleRequest(requestModel, async () => { ContentCreateModel model = _documentEditingPresentationFactory.MapCreateModel(requestModel); - Attempt result = await _contentEditingService.ValidateCreateAsync(model); + Attempt result = + await _contentEditingService.ValidateCreateAsync( + model, + CurrentUserKey(_backOfficeSecurityAccessor)); return result.Success ? Ok() diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs index 05f029f582..bbc54805d8 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs @@ -1,11 +1,14 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; @@ -17,15 +20,32 @@ public class ValidateUpdateDocumentController : UpdateDocumentControllerBase { private readonly IContentEditingService _contentEditingService; private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")] public ValidateUpdateDocumentController( IAuthorizationService authorizationService, IContentEditingService contentEditingService, IDocumentEditingPresentationFactory documentEditingPresentationFactory) + : this( + authorizationService, + contentEditingService, + documentEditingPresentationFactory, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [ActivatorUtilitiesConstructor] + public ValidateUpdateDocumentController( + IAuthorizationService authorizationService, + IContentEditingService contentEditingService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : base(authorizationService) { _contentEditingService = contentEditingService; _documentEditingPresentationFactory = documentEditingPresentationFactory; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } [HttpPut("{id:guid}/validate")] @@ -62,7 +82,11 @@ public class ValidateUpdateDocumentController : UpdateDocumentControllerBase => await HandleRequest(id, requestModel, async () => { ValidateContentUpdateModel model = _documentEditingPresentationFactory.MapValidateUpdateModel(requestModel); - Attempt result = await _contentEditingService.ValidateUpdateAsync(id, model); + Attempt result = + await _contentEditingService.ValidateUpdateAsync( + id, + model, + CurrentUserKey(_backOfficeSecurityAccessor)); return result.Success ? Ok() diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index 1dc54426d0..49ec63a7d7 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; @@ -64,7 +64,7 @@ internal sealed class ContentEditingService return await Task.FromResult(content); } - [Obsolete("Please use the validate update method that is not obsoleted. Will be removed in V16.")] + [Obsolete("Please use the validate update method that is not obsoleted. Scheduled for removal in V16.")] public async Task> ValidateUpdateAsync(Guid key, ContentUpdateModel updateModel) { IContent? content = ContentService.GetById(key); @@ -73,16 +73,50 @@ internal sealed class ContentEditingService : Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentValidationResult()); } + [Obsolete("Please use the validate update method that is not obsoleted. Scheduled for removal in V17.")] public async Task> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel) + => await ValidateUpdateAsync(key, updateModel, Guid.Empty); + + public async Task> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel, Guid userKey) { IContent? content = ContentService.GetById(key); return content is not null - ? await ValidateCulturesAndPropertiesAsync(updateModel, content.ContentType.Key, updateModel.Cultures) + ? await ValidateCulturesAndPropertiesAsync(updateModel, content.ContentType.Key, await GetCulturesToValidate(updateModel.Cultures, userKey)) : Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentValidationResult()); } + [Obsolete("Please use the validate create method that is not obsoleted. Scheduled for removal in V17.")] public async Task> ValidateCreateAsync(ContentCreateModel createModel) - => await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, createModel.Variants.Select(variant => variant.Culture)); + => await ValidateCreateAsync(createModel, Guid.Empty); + + public async Task> ValidateCreateAsync(ContentCreateModel createModel, Guid userKey) + => await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey)); + + private async Task?> GetCulturesToValidate(IEnumerable? cultures, Guid userKey) + { + // Cultures to validate can be provided by the calling code, but if the editor is restricted to only have + // access to certain languages, we don't want to validate by any they aren't allowed to edit. + + // TODO: Remove this check once the obsolete overloads to ValidateCreateAsync and ValidateUpdateAsync that don't provide a user key are removed. + // We only have this to ensure backwards compatibility with the obsolete overloads. + if (userKey == Guid.Empty) + { + return cultures; + } + + HashSet? allowedCultures = await GetAllowedCulturesForEditingUser(userKey); + + if (cultures == null) + { + // If no cultures are provided, we are asking to validate all cultures. But if the user doesn't have access to all, we + // should only validate the ones they do. + var allCultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode).ToList(); + return allowedCultures.Count == allCultures.Count ? null : allowedCultures; + } + + // If explicit cultures are provided, we should only validate the ones the user has access to. + return cultures.Where(x => !string.IsNullOrEmpty(x) && allowedCultures.Contains(x)).ToList(); + } public async Task> CreateAsync(ContentCreateModel createModel, Guid userKey) { @@ -127,16 +161,7 @@ internal sealed class ContentEditingService IContent? existingContent = await GetAsync(contentWithPotentialUnallowedChanges.Key); - IUser? user = await _userService.GetAsync(userKey); - - if (user is null) - { - return contentWithPotentialUnallowedChanges; - } - - var allowedLanguageIds = user.CalculateAllowedLanguageIds(_localizationService)!; - - var allowedCultures = (await _languageService.GetIsoCodesByIdsAsync(allowedLanguageIds)).ToHashSet(); + HashSet? allowedCultures = await GetAllowedCulturesForEditingUser(userKey); ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); @@ -211,6 +236,16 @@ internal sealed class ContentEditingService return contentWithPotentialUnallowedChanges; } + private async Task> GetAllowedCulturesForEditingUser(Guid userKey) + { + IUser? user = await _userService.GetAsync(userKey) + ?? throw new InvalidOperationException($"Could not find user by key {userKey} when editing or validating content."); + + var allowedLanguageIds = user.CalculateAllowedLanguageIds(_localizationService)!; + + return (await _languageService.GetIsoCodesByIdsAsync(allowedLanguageIds)).ToHashSet(); + } + public async Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey) { IContent? content = ContentService.GetById(key); diff --git a/src/Umbraco.Core/Services/IContentEditingService.cs b/src/Umbraco.Core/Services/IContentEditingService.cs index 260e5ae934..3dab432393 100644 --- a/src/Umbraco.Core/Services/IContentEditingService.cs +++ b/src/Umbraco.Core/Services/IContentEditingService.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services.OperationStatus; @@ -8,13 +8,25 @@ public interface IContentEditingService { Task GetAsync(Guid key); + [Obsolete("Please use the validate create method that is not obsoleted. Scheduled for removal in Umbraco 17.")] Task> ValidateCreateAsync(ContentCreateModel createModel); - [Obsolete("Please use the validate update method that is not obsoleted. Will be removed in V16.")] + Task> ValidateCreateAsync(ContentCreateModel createModel, Guid userKey) +#pragma warning disable CS0618 // Type or member is obsolete + => ValidateCreateAsync(createModel); +#pragma warning restore CS0618 // Type or member is obsolete + + [Obsolete("Please use the validate update method that is not obsoleted. Scheduled for removal in Umbraco 16.")] Task> ValidateUpdateAsync(Guid key, ContentUpdateModel updateModel); + [Obsolete("Please use the validate update method that is not obsoleted. Scheduled for removal in Umbraco 17.")] Task> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel); + Task> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel, Guid userKey) +#pragma warning disable CS0618 // Type or member is obsolete + => ValidateUpdateAsync(key, updateModel); +#pragma warning restore CS0618 // Type or member is obsolete + Task> CreateAsync(ContentCreateModel createModel, Guid userKey); Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey); diff --git a/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs b/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs index c84b118abe..2d78198649 100644 --- a/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs @@ -3,6 +3,7 @@ using Moq; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Models.Membership.Permissions; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Builders.Interfaces; @@ -27,6 +28,8 @@ public class UserGroupBuilder { private string _alias; private IEnumerable _allowedSections = Enumerable.Empty(); + private IEnumerable _allowedLanguages = Enumerable.Empty(); + private IEnumerable _granularPermissions = Enumerable.Empty(); private string _icon; private int? _id; private Guid? _key; @@ -95,13 +98,24 @@ public class UserGroupBuilder return this; } - public UserGroupBuilder WithAllowedSections(IList allowedSections) { _allowedSections = allowedSections; return this; } + public UserGroupBuilder WithAllowedLanguages(IList allowedLanguages) + { + _allowedLanguages = allowedLanguages; + return this; + } + + public UserGroupBuilder WithGranularPermissions(IList granularPermissions) + { + _granularPermissions = granularPermissions; + return this; + } + public UserGroupBuilder WithStartContentId(int startContentId) { _startContentId = startContentId; @@ -144,17 +158,40 @@ public class UserGroupBuilder Id = id, Key = key, StartContentId = startContentId, - StartMediaId = startMediaId + StartMediaId = startMediaId, + Permissions = _permissions }; - userGroup.Permissions = _permissions; + BuildAllowedSections(userGroup); + BuildAllowedLanguages(userGroup); + BuildGranularPermissions(userGroup); + return userGroup; + } + + + private void BuildAllowedSections(UserGroup userGroup) + { foreach (var section in _allowedSections) { userGroup.AddAllowedSection(section); } + } - return userGroup; + private void BuildAllowedLanguages(UserGroup userGroup) + { + foreach (var language in _allowedLanguages) + { + userGroup.AddAllowedLanguage(language); + } + } + + private void BuildGranularPermissions(UserGroup userGroup) + { + foreach (var permission in _granularPermissions) + { + userGroup.GranularPermissions.Add(permission); + } } public static UserGroup CreateUserGroup( diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs index 5e4e74b793..954012c74a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs @@ -1,4 +1,4 @@ -using NUnit.Framework; +using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; @@ -346,6 +346,7 @@ public partial class ContentEditingServiceTests InvariantName = "Updated Name", InvariantProperties = new[] { + new PropertyValueModel { Alias = "title", Value = "The initial title" }, new PropertyValueModel { Alias = "label", Value = "The updated label value" } } }; @@ -390,6 +391,7 @@ public partial class ContentEditingServiceTests Name = "Updated English Name", Properties = new [] { + new PropertyValueModel { Alias = "variantTitle", Value = "The initial English title" }, new PropertyValueModel { Alias = "variantLabel", Value = "The updated English label value" } } }, @@ -399,6 +401,7 @@ public partial class ContentEditingServiceTests Name = "Updated Danish Name", Properties = new [] { + new PropertyValueModel { Alias = "variantTitle", Value = "The initial Danish title" }, new PropertyValueModel { Alias = "variantLabel", Value = "The updated Danish label value" } } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Validate.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Validate.cs new file mode 100644 index 0000000000..1589668a78 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Validate.cs @@ -0,0 +1,191 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ContentEditingServiceTests +{ + [Test] + public async Task Can_Validate_Valid_Invariant_Content() + { + var content = await CreateInvariantContent(); + + var validateContentUpdateModel = new ValidateContentUpdateModel + { + InvariantName = "Updated Name", + InvariantProperties = + [ + new PropertyValueModel { Alias = "title", Value = "The updated title" }, + new PropertyValueModel { Alias = "text", Value = "The updated text" } + ] + }; + + Attempt result = await ContentEditingService.ValidateUpdateAsync(content.Key, validateContentUpdateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + } + + [Test] + public async Task Will_Fail_Invalid_Invariant_Content() + { + var content = await CreateInvariantContent(); + + var validateContentUpdateModel = new ValidateContentUpdateModel + { + InvariantName = "Updated Name", + InvariantProperties = + [ + new PropertyValueModel { Alias = "title", Value = null }, + new PropertyValueModel { Alias = "text", Value = "The updated text" } + ] + }; + + Attempt result = await ContentEditingService.ValidateUpdateAsync(content.Key, validateContentUpdateModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.PropertyValidationError, result.Status); + Assert.AreEqual(1, result.Result.ValidationErrors.Count()); + Assert.AreEqual("#validation_invalidNull", result.Result.ValidationErrors.Single(x => x.Alias == "title").ErrorMessages[0]); + } + + [Test] + public async Task Can_Validate_Valid_Variant_Content() + { + var content = await CreateVariantContent(); + + var validateContentUpdateModel = new ValidateContentUpdateModel + { + InvariantProperties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" } + ], + Variants = + [ + new VariantModel + { + Culture = "en-US", + Name = "Updated English Name", + Properties = + [ + new PropertyValueModel { Alias = "variantTitle", Value = "The updated English title" } + ] + }, + new VariantModel + { + Culture = "da-DK", + Name = "Updated Danish Name", + Properties = + [ + new PropertyValueModel { Alias = "variantTitle", Value = "The updated Danish title" } + ] + } + ], + }; + + Attempt result = await ContentEditingService.ValidateUpdateAsync(content.Key, validateContentUpdateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + } + + [Test] + public async Task Will_Fail_Invalid_Variant_Content() + { + var content = await CreateVariantContent(); + + var validateContentUpdateModel = new ValidateContentUpdateModel + { + InvariantProperties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" } + ], + Variants = + [ + new VariantModel + { + Culture = "en-US", + Name = "Updated English Name", + Properties = + [ + new PropertyValueModel { Alias = "variantTitle", Value = "The updated English title" } + ] + }, + new VariantModel + { + Culture = "da-DK", + Name = "Updated Danish Name", + Properties = + [ + new PropertyValueModel { Alias = "variantTitle", Value = null } + ] + } + ], + }; + + Attempt result = await ContentEditingService.ValidateUpdateAsync(content.Key, validateContentUpdateModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.PropertyValidationError, result.Status); + Assert.AreEqual(1, result.Result.ValidationErrors.Count()); + Assert.AreEqual("#validation_invalidNull", result.Result.ValidationErrors.Single(x => x.Alias == "variantTitle" && x.Culture == "da-DK").ErrorMessages[0]); + } + + [Test] + public async Task Will_Succeed_For_Invalid_Variant_Content_Without_Access_To_Edited_Culture() + { + var content = await CreateVariantContent(); + + IUser englishEditor = await CreateEnglishLanguageOnlyEditor(); + + var validateContentUpdateModel = new ValidateContentUpdateModel + { + InvariantProperties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" } + ], + Variants = + [ + new VariantModel + { + Culture = "en-US", + Name = "Updated English Name", + Properties = + [ + new PropertyValueModel { Alias = "variantTitle", Value = "The updated English title" } + ] + } + ], + }; + + Attempt result = await ContentEditingService.ValidateUpdateAsync(content.Key, validateContentUpdateModel, englishEditor.Key); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + } + + private async Task CreateEnglishLanguageOnlyEditor() + { + var enUSLanguage = await LanguageService.GetAsync("en-US"); + var userGroup = new UserGroupBuilder() + .WithName("English Editors") + .WithAlias("englishEditors") + .WithAllowedLanguages([enUSLanguage.Id]) + .Build(); + + var createUserGroupResult = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserKey); + Assert.IsTrue(createUserGroupResult.Success); + + var createUserAttempt = await UserService.CreateAsync(Constants.Security.SuperUserKey, new UserCreateModel + { + Email = "english-editor@test.com", + Name = "Test English Editor", + UserName = "english-editor@test.com", + UserGroupKeys = new[] { userGroup.Key }.ToHashSet(), + }); + Assert.IsTrue(createUserAttempt.Success); + + return await UserService.GetAsync(createUserAttempt.Result.CreatedUser.Key); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs index f9dd811f0f..ebad7f9545 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTestsBase.cs @@ -21,7 +21,11 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit protected IContentBlueprintEditingService ContentBlueprintEditingService => GetRequiredService(); - private ILanguageService LanguageService => GetRequiredService(); + protected ILanguageService LanguageService => GetRequiredService(); + + protected IUserService UserService => GetRequiredService(); + + protected IUserGroupService UserGroupService => GetRequiredService(); protected IContentType CreateInvariantContentType(params ITemplate[] templates) { @@ -30,22 +34,23 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit .WithName("Invariant Test") .WithContentVariation(ContentVariation.Nothing) .AddPropertyType() - .WithAlias("title") - .WithName("Title") - .WithVariations(ContentVariation.Nothing) - .Done() + .WithAlias("title") + .WithName("Title") + .WithMandatory(true) + .WithVariations(ContentVariation.Nothing) + .Done() .AddPropertyType() - .WithAlias("text") - .WithName("Text") - .WithVariations(ContentVariation.Nothing) - .Done() + .WithAlias("text") + .WithName("Text") + .WithVariations(ContentVariation.Nothing) + .Done() .AddPropertyType() - .WithAlias("label") - .WithName("Label") - .WithDataTypeId(Constants.DataTypes.LabelString) - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) - .WithVariations(ContentVariation.Nothing) - .Done(); + .WithAlias("label") + .WithName("Label") + .WithDataTypeId(Constants.DataTypes.LabelString) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) + .WithVariations(ContentVariation.Nothing) + .Done(); foreach (var template in templates) { @@ -81,22 +86,23 @@ public abstract class ContentEditingServiceTestsBase : UmbracoIntegrationTestWit .WithName("Culture Variation Test") .WithContentVariation(ContentVariation.Culture) .AddPropertyType() - .WithAlias("variantTitle") - .WithName("Variant Title") - .WithVariations(ContentVariation.Culture) - .Done() + .WithAlias("variantTitle") + .WithName("Variant Title") + .WithMandatory(true) + .WithVariations(ContentVariation.Culture) + .Done() .AddPropertyType() - .WithAlias("invariantTitle") - .WithName("Invariant Title") - .WithVariations(ContentVariation.Nothing) - .Done() + .WithAlias("invariantTitle") + .WithName("Invariant Title") + .WithVariations(ContentVariation.Nothing) + .Done() .AddPropertyType() - .WithAlias("variantLabel") - .WithName("Variant Label") - .WithDataTypeId(Constants.DataTypes.LabelString) - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) - .WithVariations(ContentVariation.Culture) - .Done() + .WithAlias("variantLabel") + .WithName("Variant Label") + .WithDataTypeId(Constants.DataTypes.LabelString) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) + .WithVariations(ContentVariation.Culture) + .Done() .Build(); contentType.AllowedAsRoot = true; ContentTypeService.Save(contentType); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index db1e6d27e1..30521afe57 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -1,4 +1,4 @@ - + true Umbraco.Cms.Tests.Integration @@ -97,6 +97,9 @@ ContentEditingServiceTests.cs + + ContentEditingServiceTests.cs + ContentPublishingServiceTests.cs From 6247f549768547411bee428020f68d82af413d47 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 4 Apr 2025 06:51:29 +0200 Subject: [PATCH 12/53] Adds ancestor ID details on document tree and collection responses (#18909) * Populate ancestor keys on document tree response items. * Populate ancestor keys on document collection response items. * Update OpenApi.json * Use array of objects rather than Ids for the ancestor collection. * Update OpenApi.json. --- .../Tree/DocumentTreeControllerBase.cs | 5 +++- .../DocumentCollectionPresentationFactory.cs | 19 ++++++++++++++- .../Mapping/Document/DocumentMapDefinition.cs | 2 +- src/Umbraco.Cms.Api.Management/OpenApi.json | 22 +++++++++++++++++ .../DocumentCollectionResponseModel.cs | 2 ++ .../Tree/DocumentTreeItemResponseModel.cs | 3 ++- src/Umbraco.Core/Services/EntityService.cs | 24 ++++++++++++++++++- src/Umbraco.Core/Services/IEntityService.cs | 8 +++++++ .../Services/EntityServiceTests.cs | 21 ++++++++++++++++ 9 files changed, 101 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/DocumentTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/DocumentTreeControllerBase.cs index 33e451bdc5..cacf862b57 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/DocumentTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/DocumentTreeControllerBase.cs @@ -4,6 +4,7 @@ using Umbraco.Cms.Api.Management.Controllers.Tree; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Routing; using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; @@ -33,7 +34,7 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa AppCaches appCaches, IBackOfficeSecurityAccessor backofficeSecurityAccessor, IDocumentPresentationFactory documentPresentationFactory) - : base(entityService, userStartNodeEntitiesService, dataTypeService) + : base(entityService, userStartNodeEntitiesService, dataTypeService) { _publicAccessService = publicAccessService; _appCaches = appCaches; @@ -52,6 +53,8 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa if (entity is IDocumentEntitySlim documentEntitySlim) { responseModel.IsProtected = _publicAccessService.IsProtected(entity.Path); + responseModel.Ancestors = EntityService.GetPathKeys(entity, omitSelf: true) + .Select(x => new ReferenceByIdModel(x)); responseModel.IsTrashed = entity.Trashed; responseModel.Id = entity.Key; responseModel.CreateDate = entity.CreateDate; diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs index 228952b469..b63704cbb2 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentCollectionPresentationFactory.cs @@ -1,5 +1,8 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.Document.Collection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -9,11 +12,23 @@ namespace Umbraco.Cms.Api.Management.Factories; public class DocumentCollectionPresentationFactory : ContentCollectionPresentationFactory, IDocumentCollectionPresentationFactory { private readonly IPublicAccessService _publicAccessService; + private readonly IEntityService _entityService; + [Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V17.")] public DocumentCollectionPresentationFactory(IUmbracoMapper mapper, IPublicAccessService publicAccessService) - : base(mapper) + : this( + mapper, + publicAccessService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [ActivatorUtilitiesConstructor] + public DocumentCollectionPresentationFactory(IUmbracoMapper mapper, IPublicAccessService publicAccessService, IEntityService entityService) + : base(mapper) { _publicAccessService = publicAccessService; + _entityService = entityService; } protected override Task SetUnmappedProperties(ListViewPagedModel contentCollection, List collectionResponseModels) @@ -27,6 +42,8 @@ public class DocumentCollectionPresentationFactory : ContentCollectionPresentati } item.IsProtected = _publicAccessService.IsProtected(matchingContentItem).Success; + item.Ancestors = _entityService.GetPathKeys(matchingContentItem, omitSelf: true) + .Select(x => new ReferenceByIdModel(x)); } return Task.CompletedTask; diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs index dbc51a6f41..15eb6bafd8 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs @@ -67,7 +67,7 @@ public class DocumentMapDefinition : ContentMapDefinition Ancestors { get; set; } = []; + public string? Updater { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/DocumentTreeItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/DocumentTreeItemResponseModel.cs index 094522b91a..1bde763102 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/DocumentTreeItemResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/DocumentTreeItemResponseModel.cs @@ -1,4 +1,3 @@ -using Umbraco.Cms.Api.Management.ViewModels.Content; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.DocumentType; @@ -8,6 +7,8 @@ public class DocumentTreeItemResponseModel : ContentTreeItemResponseModel { public bool IsProtected { get; set; } + public IEnumerable Ancestors { get; set; } = []; + public DocumentTypeReferenceResponseModel DocumentType { get; set; } = new(); public IEnumerable Variants { get; set; } = Enumerable.Empty(); diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index 7f2ba473b7..f2fe2eefd8 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Linq.Expressions; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; @@ -758,5 +759,26 @@ public class EntityService : RepositoryService, IEntityService return _entityRepository.GetPagedResultsByQuery(query, objectTypeGuids, pageNumber, pageSize, out totalRecords, filter, ordering); } } -} + /// > + public Guid[] GetPathKeys(ITreeEntity entity, bool omitSelf = false) + { + IEnumerable ids = entity.Path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries) + .Select(x => int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val) ? val : -1) + .Where(x => x != -1); + + Guid[] keys = ids + .Select(x => _idKeyMap.GetKeyForId(x, UmbracoObjectTypes.Document)) + .Where(x => x.Success) + .Select(x => x.Result) + .ToArray(); + + if (omitSelf) + { + // Omit the last path key as that will be for the item itself. + return keys.Take(keys.Length - 1).ToArray(); + } + + return keys; + } +} diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index 08ff2feb8c..964ec9f502 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -382,4 +382,12 @@ public interface IEntityService /// The identifier. /// When a new content or a media is saved with the key, it will have the reserved identifier. int ReserveId(Guid key); + + /// + /// Gets the GUID keys for an entity's path (provided as a comma separated list of integer Ids). + /// + /// The entity. + /// A value indicating whether to omit the entity's own key from the result. + /// The path with each ID converted to a GUID. + Guid[] GetPathKeys(ITreeEntity entity, bool omitSelf = false) => []; } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs index 20df8e690a..b6b7f2cb64 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs @@ -910,6 +910,27 @@ public class EntityServiceTests : UmbracoIntegrationTest Assert.IsFalse(EntityService.GetId(Guid.NewGuid(), UmbracoObjectTypes.DocumentType).Success); } + [Test] + public void EntityService_GetPathKeys_ReturnsExpectedKeys() + { + var contentType = ContentTypeService.Get("umbTextpage"); + + var root = ContentBuilder.CreateSimpleContent(contentType); + ContentService.Save(root); + + var child = ContentBuilder.CreateSimpleContent(contentType, Guid.NewGuid().ToString(), root); + ContentService.Save(child); + var grandChild = ContentBuilder.CreateSimpleContent(contentType, Guid.NewGuid().ToString(), child); + ContentService.Save(grandChild); + + var result = EntityService.GetPathKeys(grandChild); + Assert.AreEqual($"{root.Key},{child.Key},{grandChild.Key}", string.Join(",", result)); + + var result2 = EntityService.GetPathKeys(grandChild, omitSelf: true); + Assert.AreEqual($"{root.Key},{child.Key}", string.Join(",", result2)); + + } + private static bool _isSetup; private int _folderId; From ad7053af36fe953c0ca94d1f590906e378142c76 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 4 Apr 2025 07:42:26 +0200 Subject: [PATCH 13/53] Move publish with descendants to a background task with polling (#18497) * Use background queue for database cache rebuild and track rebuilding status. * Updated OpenApi.json and client-side types. * Updated client to poll for completion of database rebuild. * Move IBackgroundTaskQueue to core and prepare publish branch to run as background task. * Endpoints for retrieval of status and result from branch publish operations. * Poll and retrieve result for publish with descendants. * Handled issues from testing. * Rework to single controller for status and result. * Updated client side sdk. * OpenApi post dev merge gen --------- Co-authored-by: Migaroez --- .../Document/DocumentControllerBase.cs | 6 +- ...ublishDocumentWithDescendantsController.cs | 13 +- ...DocumentWithDescendantsResultController.cs | 69 ++++++++ .../RebuildPublishedCacheController.cs | 2 +- src/Umbraco.Cms.Api.Management/OpenApi.json | 113 +++++++++++- .../PublishWithDescendantsResultModel.cs | 8 + .../HostedServices/IBackgroundTaskQueue.cs | 20 +++ .../ContentPublishingBranchResult.cs | 4 +- .../Services/ContentPublishingService.cs | 161 ++++++++++++++---- src/Umbraco.Core/Services/ContentService.cs | 2 +- .../Services/IContentPublishingService.cs | 31 +++- .../ContentPublishingOperationStatus.cs | 5 +- .../UmbracoBuilder.CoreServices.cs | 11 +- ...veryApiContentIndexHandleContentChanges.cs | 4 +- ...ApiContentIndexHandleContentTypeChanges.cs | 5 +- ...piContentIndexHandlePublicAccessChanges.cs | 4 +- .../Examine/DeliveryApiIndexingHandler.cs | 4 +- .../Examine/ExamineUmbracoIndexingHandler.cs | 2 +- .../HostedServices/BackgroundTaskQueue.cs | 2 + .../HostedServices/IBackgroundTaskQueue.cs | 12 +- .../DatabaseCacheRebuilder.cs | 2 +- .../src/external/backend-api/src/sdk.gen.ts | 29 +++- .../src/external/backend-api/src/types.gen.ts | 14 +- .../document-publishing.repository.ts | 4 + .../document-publishing.server.data-source.ts | 27 ++- .../ContentPublishingServiceTests.Publish.cs | 20 +-- 26 files changed, 489 insertions(+), 85 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishWithDescendantsResultModel.cs create mode 100644 src/Umbraco.Core/HostedServices/IBackgroundTaskQueue.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs index 52b2f34d7f..10f1931313 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Controllers.Content; @@ -140,6 +140,10 @@ public abstract class DocumentControllerBase : ContentControllerBase .WithDetail( "An unspecified error occurred while (un)publishing. Please check the logs for additional information.") .Build()), + ContentPublishingOperationStatus.TaskResultNotFound => NotFound(problemDetailsBuilder + .WithTitle("The result of the submitted task could not be found") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."), }); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs index 869bc4c880..f9d9681f07 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs @@ -35,7 +35,7 @@ public class PublishDocumentWithDescendantsController : DocumentControllerBase [HttpPut("{id:guid}/publish-with-descendants")] [MapToApiVersion("1.0")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(PublishWithDescendantsResultModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task PublishWithDescendants(CancellationToken cancellationToken, Guid id, PublishDocumentWithDescendantsRequestModel requestModel) @@ -54,10 +54,15 @@ public class PublishDocumentWithDescendantsController : DocumentControllerBase id, requestModel.Cultures, BuildPublishBranchFilter(requestModel), - CurrentUserKey(_backOfficeSecurityAccessor)); + CurrentUserKey(_backOfficeSecurityAccessor), + true); - return attempt.Success - ? Ok() + return attempt.Success && attempt.Result.AcceptedTaskId.HasValue + ? Ok(new PublishWithDescendantsResultModel + { + TaskId = attempt.Result.AcceptedTaskId.Value, + IsComplete = false + }) : DocumentPublishingOperationStatusResult(attempt.Status, failedBranchItems: attempt.Result.FailedItems); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs new file mode 100644 index 0000000000..9a499ede1e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs @@ -0,0 +1,69 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +[ApiVersion("1.0")] +public class PublishDocumentWithDescendantsResultController : DocumentControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IContentPublishingService _contentPublishingService; + + public PublishDocumentWithDescendantsResultController( + IAuthorizationService authorizationService, + IContentPublishingService contentPublishingService) + { + _authorizationService = authorizationService; + _contentPublishingService = contentPublishingService; + } + + [HttpGet("{id:guid}/publish-with-descendants/result/{taskId:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PublishWithDescendantsResultModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task PublishWithDescendantsResult(CancellationToken cancellationToken, Guid id, Guid taskId) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.Branch(ActionPublish.ActionLetter, id), + AuthorizationPolicies.ContentPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + // Check if the publishing task has completed, if not, return the status. + var isPublishing = await _contentPublishingService.IsPublishingBranchAsync(taskId); + if (isPublishing) + { + return Ok(new PublishWithDescendantsResultModel + { + TaskId = taskId, + IsComplete = false + }); + }; + + // If completed, get the result and return the status. + Attempt attempt = await _contentPublishingService.GetPublishBranchResultAsync(taskId); + return attempt.Success + ? Ok(new PublishWithDescendantsResultModel + { + TaskId = taskId, + IsComplete = true + }) + : DocumentPublishingOperationStatusResult(attempt.Status, failedBranchItems: attempt.Result.FailedItems); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs index 8592f6722b..f9c88159c4 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs @@ -21,7 +21,7 @@ public class RebuildPublishedCacheController : PublishedCacheControllerBase { var problemDetails = new ProblemDetails { - Title = "Database cache can not be rebuilt", + Title = "Database cache cannot be rebuilt", Detail = $"The database cache is in the process of rebuilding.", Status = StatusCodes.Status400BadRequest, Type = "Error", diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 665c1f05d5..239aebc54a 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -8902,6 +8902,17 @@ "nullable": true } } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PublishWithDescendantsResultModel" + } + ] + } + } } }, "400": { @@ -8982,6 +8993,89 @@ ] } }, + "/umbraco/management/api/v1/document/{id}/publish-with-descendants/result/{taskId}": { + "get": { + "tags": [ + "Document" + ], + "operationId": "GetDocumentByIdPublishWithDescendantsResultByTaskId", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "taskId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PublishWithDescendantsResultModel" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/document/{id}/published": { "get": { "tags": [ @@ -43154,6 +43248,23 @@ }, "additionalProperties": false }, + "PublishWithDescendantsResultModel": { + "required": [ + "isComplete", + "taskId" + ], + "type": "object", + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + }, + "isComplete": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "PublishedDocumentResponseModel": { "required": [ "documentType", @@ -46815,4 +46926,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishWithDescendantsResultModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishWithDescendantsResultModel.cs new file mode 100644 index 0000000000..8bc88600b0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishWithDescendantsResultModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class PublishWithDescendantsResultModel +{ + public Guid TaskId { get; set; } + + public bool IsComplete { get; set; } +} diff --git a/src/Umbraco.Core/HostedServices/IBackgroundTaskQueue.cs b/src/Umbraco.Core/HostedServices/IBackgroundTaskQueue.cs new file mode 100644 index 0000000000..0fef380e8f --- /dev/null +++ b/src/Umbraco.Core/HostedServices/IBackgroundTaskQueue.cs @@ -0,0 +1,20 @@ +namespace Umbraco.Cms.Core.HostedServices; + +/// +/// A Background Task Queue, to enqueue tasks for executing in the background. +/// +/// +/// Borrowed from https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-5.0 +/// +public interface IBackgroundTaskQueue +{ + /// + /// Enqueue a work item to be executed on in the background. + /// + void QueueBackgroundWorkItem(Func workItem); + + /// + /// Dequeue the first item on the queue. + /// + Task?> DequeueAsync(CancellationToken cancellationToken); +} diff --git a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs index 7e09a96225..476f20b821 100644 --- a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs +++ b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Core.Models.ContentPublishing; +namespace Umbraco.Cms.Core.Models.ContentPublishing; public sealed class ContentPublishingBranchResult { @@ -7,4 +7,6 @@ public sealed class ContentPublishingBranchResult public IEnumerable SucceededItems { get; set; } = []; public IEnumerable FailedItems { get; set; } = []; + + public Guid? AcceptedTaskId { get; init; } } diff --git a/src/Umbraco.Core/Services/ContentPublishingService.cs b/src/Umbraco.Core/Services/ContentPublishingService.cs index f5c4a11cb5..5f72c36863 100644 --- a/src/Umbraco.Core/Services/ContentPublishingService.cs +++ b/src/Umbraco.Core/Services/ContentPublishingService.cs @@ -1,6 +1,9 @@ +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentPublishing; @@ -12,6 +15,9 @@ namespace Umbraco.Cms.Core.Services; internal sealed class ContentPublishingService : IContentPublishingService { + private const string IsPublishingBranchRuntimeCacheKeyPrefix = "temp_indexing_op_"; + private const string PublishingBranchResultCacheKeyPrefix = "temp_indexing_result_"; + private readonly ICoreScopeProvider _coreScopeProvider; private readonly IContentService _contentService; private readonly IUserIdKeyResolver _userIdKeyResolver; @@ -20,6 +26,9 @@ internal sealed class ContentPublishingService : IContentPublishingService private readonly ILanguageService _languageService; private ContentSettings _contentSettings; private readonly IRelationService _relationService; + private readonly ILogger _logger; + private readonly IBackgroundTaskQueue _backgroundTaskQueue; + private readonly IAppPolicyCache _runtimeCache; public ContentPublishingService( ICoreScopeProvider coreScopeProvider, @@ -29,7 +38,10 @@ internal sealed class ContentPublishingService : IContentPublishingService IContentTypeService contentTypeService, ILanguageService languageService, IOptionsMonitor optionsMonitor, - IRelationService relationService) + IRelationService relationService, + ILogger logger, + IBackgroundTaskQueue backgroundTaskQueue, + IAppPolicyCache runtimeCache) { _coreScopeProvider = coreScopeProvider; _contentService = contentService; @@ -38,6 +50,9 @@ internal sealed class ContentPublishingService : IContentPublishingService _contentTypeService = contentTypeService; _languageService = languageService; _relationService = relationService; + _logger = logger; + _backgroundTaskQueue = backgroundTaskQueue; + _runtimeCache = runtimeCache; _contentSettings = optionsMonitor.CurrentValue; optionsMonitor.OnChange((contentSettings) => { @@ -251,54 +266,132 @@ internal sealed class ContentPublishingService : IContentPublishingService } /// - [Obsolete("This method is not longer used as the 'force' parameter has been split into publishing unpublished and force re-published. Please use the overload containing parameters for those options instead. Will be removed in V17.")] + [Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead. Scheduled for removal in Umbraco 17.")] public async Task> PublishBranchAsync(Guid key, IEnumerable cultures, bool force, Guid userKey) => await PublishBranchAsync(key, cultures, force ? PublishBranchFilter.IncludeUnpublished : PublishBranchFilter.Default, userKey); /// + [Obsolete("Please use the overload containing all parameters. Scheduled for removal in Umbraco 17.")] public async Task> PublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey) + => await PublishBranchAsync(key, cultures, publishBranchFilter, userKey, false); + + /// + public async Task> PublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey, bool useBackgroundThread) { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - IContent? content = _contentService.GetById(key); - if (content is null) + if (useBackgroundThread) { - return Attempt.FailWithStatus( - ContentPublishingOperationStatus.ContentNotFound, - new ContentPublishingBranchResult + _logger.LogInformation("Starting async background thread for publishing branch."); + + var taskId = Guid.NewGuid(); + _backgroundTaskQueue.QueueBackgroundWorkItem( + cancellationToken => { - FailedItems = new[] + using (ExecutionContext.SuppressFlow()) { - new ContentPublishingBranchItemResult - { - Key = key, OperationStatus = ContentPublishingOperationStatus.ContentNotFound - } + Task.Run(async () => await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey, taskId) ); + return Task.CompletedTask; } }); + + return Attempt.SucceedWithStatus(ContentPublishingOperationStatus.Accepted, new ContentPublishingBranchResult { AcceptedTaskId = taskId}); } - - var userId = await _userIdKeyResolver.GetAsync(userKey); - IEnumerable result = _contentService.PublishBranch(content, publishBranchFilter, cultures.ToArray(), userId); - scope.Complete(); - - var itemResults = result.ToDictionary(r => r.Content.Key, ToContentPublishingOperationStatus); - var branchResult = new ContentPublishingBranchResult + else { - Content = content, - SucceededItems = itemResults - .Where(i => i.Value is ContentPublishingOperationStatus.Success) - .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) - .ToArray(), - FailedItems = itemResults - .Where(i => i.Value is not ContentPublishingOperationStatus.Success) - .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) - .ToArray() - }; - - return branchResult.FailedItems.Any() is false - ? Attempt.SucceedWithStatus(ContentPublishingOperationStatus.Success, branchResult) - : Attempt.FailWithStatus(ContentPublishingOperationStatus.FailedBranch, branchResult); + return await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey); + } } + private async Task> PerformPublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey, Guid? taskId = null) + { + try + { + if (taskId.HasValue) + { + SetIsPublishingBranch(taskId.Value); + } + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + IContent? content = _contentService.GetById(key); + if (content is null) + { + return Attempt.FailWithStatus( + ContentPublishingOperationStatus.ContentNotFound, + new ContentPublishingBranchResult + { + FailedItems = new[] + { + new ContentPublishingBranchItemResult + { + Key = key, + OperationStatus = ContentPublishingOperationStatus.ContentNotFound, + } + } + }); + } + + var userId = await _userIdKeyResolver.GetAsync(userKey); + IEnumerable result = _contentService.PublishBranch(content, publishBranchFilter, cultures.ToArray(), userId); + scope.Complete(); + + var itemResults = result.ToDictionary(r => r.Content.Key, ToContentPublishingOperationStatus); + var branchResult = new ContentPublishingBranchResult + { + Content = content, + SucceededItems = itemResults + .Where(i => i.Value is ContentPublishingOperationStatus.Success) + .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) + .ToArray(), + FailedItems = itemResults + .Where(i => i.Value is not ContentPublishingOperationStatus.Success) + .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) + .ToArray() + }; + + Attempt attempt = branchResult.FailedItems.Any() is false + ? Attempt.SucceedWithStatus(ContentPublishingOperationStatus.Success, branchResult) + : Attempt.FailWithStatus(ContentPublishingOperationStatus.FailedBranch, branchResult); + if (taskId.HasValue) + { + SetPublishingBranchResult(taskId.Value, attempt); + } + + return attempt; + } + finally + { + if (taskId.HasValue) + { + ClearIsPublishingBranch(taskId.Value); + } + } + } + + /// + public Task IsPublishingBranchAsync(Guid taskId) => Task.FromResult(_runtimeCache.Get(GetIsPublishingBranchCacheKey(taskId)) is not null); + + /// + public Task> GetPublishBranchResultAsync(Guid taskId) + { + var taskResult = _runtimeCache.Get(GetPublishingBranchResultCacheKey(taskId)) as Attempt?; + if (taskResult is null) + { + return Task.FromResult(Attempt.FailWithStatus(ContentPublishingOperationStatus.TaskResultNotFound, new ContentPublishingBranchResult())); + } + + // We won't clear the cache here just in case we remove references to the returned object. It expires after 60 seconds anyway. + return Task.FromResult(taskResult.Value); + } + + private void SetIsPublishingBranch(Guid taskId) => _runtimeCache.Insert(GetIsPublishingBranchCacheKey(taskId), () => "tempValue", TimeSpan.FromMinutes(10)); + + private void ClearIsPublishingBranch(Guid taskId) => _runtimeCache.Clear(GetIsPublishingBranchCacheKey(taskId)); + + private static string GetIsPublishingBranchCacheKey(Guid taskId) => IsPublishingBranchRuntimeCacheKeyPrefix + taskId; + + private void SetPublishingBranchResult(Guid taskId, Attempt result) + => _runtimeCache.Insert(GetPublishingBranchResultCacheKey(taskId), () => result, TimeSpan.FromMinutes(1)); + + private static string GetPublishingBranchResultCacheKey(Guid taskId) => PublishingBranchResultCacheKeyPrefix + taskId; /// public async Task> UnpublishAsync(Guid key, ISet? cultures, Guid userKey) diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index f20c3df4af..0719ce4e6a 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -3377,7 +3377,7 @@ public class ContentService : RepositoryService, IContentService content.Name, content.Id, culture, - "document is culture awaiting release"); + "document has culture awaiting release"); } return new PublishResult( diff --git a/src/Umbraco.Core/Services/IContentPublishingService.cs b/src/Umbraco.Core/Services/IContentPublishingService.cs index bf41028977..f2d7edef2c 100644 --- a/src/Umbraco.Core/Services/IContentPublishingService.cs +++ b/src/Umbraco.Core/Services/IContentPublishingService.cs @@ -27,7 +27,7 @@ public interface IContentPublishingService /// A value indicating whether to force-publish content that is not already published. /// The identifier of the user performing the operation. /// Result of the publish operation. - [Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead. Will be removed in V17.")] + [Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead. Scheduled for removal in Umbraco 17.")] Task> PublishBranchAsync(Guid key, IEnumerable cultures, bool force, Guid userKey); /// @@ -38,11 +38,40 @@ public interface IContentPublishingService /// A value indicating options for force publishing unpublished or re-publishing unchanged content. /// The identifier of the user performing the operation. /// Result of the publish operation. + [Obsolete("Please use the overload containing all parameters. Scheduled for removal in Umbraco 17.")] Task> PublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey) #pragma warning disable CS0618 // Type or member is obsolete => PublishBranchAsync(key, cultures, publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished), userKey); #pragma warning restore CS0618 // Type or member is obsolete + /// + /// Publishes a content branch. + /// + /// The key of the root content. + /// The cultures to publish. + /// A value indicating options for force publishing unpublished or re-publishing unchanged content. + /// The identifier of the user performing the operation. + /// Flag indicating whether to use a background thread for the operation and immediately return to the caller. + /// Result of the publish operation. + Task> PublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey, bool useBackgroundThread) +#pragma warning disable CS0618 // Type or member is obsolete + => PublishBranchAsync(key, cultures, publishBranchFilter, userKey); +#pragma warning restore CS0618 // Type or member is obsolete + + /// + /// Gets the status of a background task that is publishing a content branch. + /// + /// The task Id. + /// True if the requested publish branch tag is still in process. + Task IsPublishingBranchAsync(Guid taskId) => Task.FromResult(false); + + /// + /// Retrieves the result of a background task that has published a content branch. + /// + /// The task Id. + /// Result of the publish operation. + Task> GetPublishBranchResultAsync(Guid taskId) => Task.FromResult(Attempt.FailWithStatus(ContentPublishingOperationStatus.TaskResultNotFound, new ContentPublishingBranchResult())); + /// /// Unpublishes multiple cultures of a single content item. /// diff --git a/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs index 25c9a26389..329aa0d224 100644 --- a/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Core.Services.OperationStatus; +namespace Umbraco.Cms.Core.Services.OperationStatus; public enum ContentPublishingOperationStatus { @@ -27,5 +27,6 @@ public enum ContentPublishingOperationStatus Failed, // unspecified failure (can happen on unpublish at the time of writing) Unknown, CannotUnpublishWhenReferenced, - + Accepted, + TaskResultNotFound, } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 74fa4f374d..61bd07dd3f 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -14,6 +14,7 @@ using Umbraco.Cms.Core.DistributedLocking; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Handlers; using Umbraco.Cms.Core.HealthChecks.NotificationMethods; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Install; using Umbraco.Cms.Core.Logging.Serilog.Enrichers; @@ -43,7 +44,6 @@ using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Infrastructure.DistributedLocking; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Infrastructure.HealthChecks; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.Install; using Umbraco.Cms.Infrastructure.Mail; using Umbraco.Cms.Infrastructure.Mail.Interfaces; @@ -227,8 +227,13 @@ public static partial class UmbracoBuilderExtensions builder.AddInstaller(); - // Services required to run background jobs (with out the handler) - builder.Services.AddSingleton(); + // Services required to run background jobs + // We can simplify this registration once the obsolete IBackgroundTaskQueue is removed. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(s => s.GetRequiredService()); +#pragma warning disable CS0618 // Type or member is obsolete + builder.Services.AddSingleton(s => s.GetRequiredService()); +#pragma warning restore CS0618 // Type or member is obsolete builder.Services.AddTransient(); diff --git a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentChanges.cs b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentChanges.cs index 66859edd7d..0b4310c4b4 100644 --- a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentChanges.cs +++ b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentChanges.cs @@ -1,8 +1,8 @@ -using Examine; +using Examine; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Changes; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Examine.Deferred; diff --git a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs index 0cdfc10db6..f2ced86a83 100644 --- a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs +++ b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs @@ -1,13 +1,12 @@ -using Examine; +using Examine; using Examine.Search; using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Changes; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Examine.Deferred; diff --git a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs index 01449f37a6..6a9733fde8 100644 --- a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs +++ b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs @@ -1,9 +1,9 @@ -using Examine; +using Examine; using Examine.Search; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Examine.Deferred; diff --git a/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs b/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs index 750cb4780b..edd666e6fc 100644 --- a/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs +++ b/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs @@ -1,13 +1,13 @@ -using Examine; +using Examine; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Changes; using Umbraco.Cms.Infrastructure.Examine.Deferred; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.Search; namespace Umbraco.Cms.Infrastructure.Examine; diff --git a/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs b/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs index 4efdf9ff12..2ca1bc8f79 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs @@ -2,10 +2,10 @@ using System.Globalization; using Examine; using Examine.Search; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.Search; using Umbraco.Extensions; diff --git a/src/Umbraco.Infrastructure/HostedServices/BackgroundTaskQueue.cs b/src/Umbraco.Infrastructure/HostedServices/BackgroundTaskQueue.cs index 522fae5c4d..55b28f984e 100644 --- a/src/Umbraco.Infrastructure/HostedServices/BackgroundTaskQueue.cs +++ b/src/Umbraco.Infrastructure/HostedServices/BackgroundTaskQueue.cs @@ -8,7 +8,9 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// /// Borrowed from https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-5.0 /// +#pragma warning disable CS0618 // Type or member is obsolete public class BackgroundTaskQueue : IBackgroundTaskQueue +#pragma warning restore CS0618 // Type or member is obsolete { private readonly SemaphoreSlim _signal = new(0); diff --git a/src/Umbraco.Infrastructure/HostedServices/IBackgroundTaskQueue.cs b/src/Umbraco.Infrastructure/HostedServices/IBackgroundTaskQueue.cs index aa89d59d77..983af4be9a 100644 --- a/src/Umbraco.Infrastructure/HostedServices/IBackgroundTaskQueue.cs +++ b/src/Umbraco.Infrastructure/HostedServices/IBackgroundTaskQueue.cs @@ -6,15 +6,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// /// Borrowed from https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-5.0 /// -public interface IBackgroundTaskQueue +[Obsolete("This has been relocated into Umbraco.Cms.Core. This definition in Umbraco.Cms.Infrastructure is scheduled for removal in Umbraco 17.")] +public interface IBackgroundTaskQueue : Core.HostedServices.IBackgroundTaskQueue { - /// - /// Enqueue a work item to be executed on in the background. - /// - void QueueBackgroundWorkItem(Func workItem); - - /// - /// Dequeue the first item on the queue. - /// - Task?> DequeueAsync(CancellationToken cancellationToken); } diff --git a/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs b/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs index e6daf8b3b6..1823825a9f 100644 --- a/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs +++ b/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs @@ -2,11 +2,11 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.HybridCache.Persistence; namespace Umbraco.Cms.Infrastructure.HybridCache; diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts index ae4012fb62..770d927fa6 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { GetCultureData, GetCultureResponse, PostDataTypeData, PostDataTypeResponse, GetDataTypeByIdData, GetDataTypeByIdResponse, DeleteDataTypeByIdData, DeleteDataTypeByIdResponse, PutDataTypeByIdData, PutDataTypeByIdResponse, PostDataTypeByIdCopyData, PostDataTypeByIdCopyResponse, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedResponse, PutDataTypeByIdMoveData, PutDataTypeByIdMoveResponse, GetDataTypeByIdReferencesData, GetDataTypeByIdReferencesResponse, GetDataTypeConfigurationResponse, PostDataTypeFolderData, PostDataTypeFolderResponse, GetDataTypeFolderByIdData, GetDataTypeFolderByIdResponse, DeleteDataTypeFolderByIdData, DeleteDataTypeFolderByIdResponse, PutDataTypeFolderByIdData, PutDataTypeFolderByIdResponse, GetFilterDataTypeData, GetFilterDataTypeResponse, GetItemDataTypeData, GetItemDataTypeResponse, GetItemDataTypeSearchData, GetItemDataTypeSearchResponse, GetTreeDataTypeAncestorsData, GetTreeDataTypeAncestorsResponse, GetTreeDataTypeChildrenData, GetTreeDataTypeChildrenResponse, GetTreeDataTypeRootData, GetTreeDataTypeRootResponse, GetDictionaryData, GetDictionaryResponse, PostDictionaryData, PostDictionaryResponse, GetDictionaryByIdData, GetDictionaryByIdResponse, DeleteDictionaryByIdData, DeleteDictionaryByIdResponse, PutDictionaryByIdData, PutDictionaryByIdResponse, GetDictionaryByIdExportData, GetDictionaryByIdExportResponse, PutDictionaryByIdMoveData, PutDictionaryByIdMoveResponse, PostDictionaryImportData, PostDictionaryImportResponse, GetItemDictionaryData, GetItemDictionaryResponse, GetTreeDictionaryAncestorsData, GetTreeDictionaryAncestorsResponse, GetTreeDictionaryChildrenData, GetTreeDictionaryChildrenResponse, GetTreeDictionaryRootData, GetTreeDictionaryRootResponse, GetCollectionDocumentByIdData, GetCollectionDocumentByIdResponse, PostDocumentData, PostDocumentResponse, GetDocumentByIdData, GetDocumentByIdResponse, DeleteDocumentByIdData, DeleteDocumentByIdResponse, PutDocumentByIdData, PutDocumentByIdResponse, GetDocumentByIdAuditLogData, GetDocumentByIdAuditLogResponse, PostDocumentByIdCopyData, PostDocumentByIdCopyResponse, GetDocumentByIdDomainsData, GetDocumentByIdDomainsResponse, PutDocumentByIdDomainsData, PutDocumentByIdDomainsResponse, PutDocumentByIdMoveData, PutDocumentByIdMoveResponse, PutDocumentByIdMoveToRecycleBinData, PutDocumentByIdMoveToRecycleBinResponse, GetDocumentByIdNotificationsData, GetDocumentByIdNotificationsResponse, PutDocumentByIdNotificationsData, PutDocumentByIdNotificationsResponse, PostDocumentByIdPublicAccessData, PostDocumentByIdPublicAccessResponse, DeleteDocumentByIdPublicAccessData, DeleteDocumentByIdPublicAccessResponse, GetDocumentByIdPublicAccessData, GetDocumentByIdPublicAccessResponse, PutDocumentByIdPublicAccessData, PutDocumentByIdPublicAccessResponse, PutDocumentByIdPublishData, PutDocumentByIdPublishResponse, PutDocumentByIdPublishWithDescendantsData, PutDocumentByIdPublishWithDescendantsResponse, GetDocumentByIdPublishedData, GetDocumentByIdPublishedResponse, GetDocumentByIdReferencedByData, GetDocumentByIdReferencedByResponse, GetDocumentByIdReferencedDescendantsData, GetDocumentByIdReferencedDescendantsResponse, PutDocumentByIdUnpublishData, PutDocumentByIdUnpublishResponse, PutDocumentByIdValidateData, PutDocumentByIdValidateResponse, PutUmbracoManagementApiV11DocumentByIdValidate11Data, PutUmbracoManagementApiV11DocumentByIdValidate11Response, GetDocumentAreReferencedData, GetDocumentAreReferencedResponse, GetDocumentConfigurationResponse, PutDocumentSortData, PutDocumentSortResponse, GetDocumentUrlsData, GetDocumentUrlsResponse, PostDocumentValidateData, PostDocumentValidateResponse, GetItemDocumentData, GetItemDocumentResponse, GetItemDocumentSearchData, GetItemDocumentSearchResponse, DeleteRecycleBinDocumentResponse, DeleteRecycleBinDocumentByIdData, DeleteRecycleBinDocumentByIdResponse, GetRecycleBinDocumentByIdOriginalParentData, GetRecycleBinDocumentByIdOriginalParentResponse, PutRecycleBinDocumentByIdRestoreData, PutRecycleBinDocumentByIdRestoreResponse, GetRecycleBinDocumentChildrenData, GetRecycleBinDocumentChildrenResponse, GetRecycleBinDocumentRootData, GetRecycleBinDocumentRootResponse, GetTreeDocumentAncestorsData, GetTreeDocumentAncestorsResponse, GetTreeDocumentChildrenData, GetTreeDocumentChildrenResponse, GetTreeDocumentRootData, GetTreeDocumentRootResponse, PostDocumentBlueprintData, PostDocumentBlueprintResponse, GetDocumentBlueprintByIdData, GetDocumentBlueprintByIdResponse, DeleteDocumentBlueprintByIdData, DeleteDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdData, PutDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdMoveData, PutDocumentBlueprintByIdMoveResponse, PostDocumentBlueprintFolderData, PostDocumentBlueprintFolderResponse, GetDocumentBlueprintFolderByIdData, GetDocumentBlueprintFolderByIdResponse, DeleteDocumentBlueprintFolderByIdData, DeleteDocumentBlueprintFolderByIdResponse, PutDocumentBlueprintFolderByIdData, PutDocumentBlueprintFolderByIdResponse, PostDocumentBlueprintFromDocumentData, PostDocumentBlueprintFromDocumentResponse, GetItemDocumentBlueprintData, GetItemDocumentBlueprintResponse, GetTreeDocumentBlueprintAncestorsData, GetTreeDocumentBlueprintAncestorsResponse, GetTreeDocumentBlueprintChildrenData, GetTreeDocumentBlueprintChildrenResponse, GetTreeDocumentBlueprintRootData, GetTreeDocumentBlueprintRootResponse, PostDocumentTypeData, PostDocumentTypeResponse, GetDocumentTypeByIdData, GetDocumentTypeByIdResponse, DeleteDocumentTypeByIdData, DeleteDocumentTypeByIdResponse, PutDocumentTypeByIdData, PutDocumentTypeByIdResponse, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenResponse, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintResponse, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesResponse, PostDocumentTypeByIdCopyData, PostDocumentTypeByIdCopyResponse, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportResponse, PutDocumentTypeByIdImportData, PutDocumentTypeByIdImportResponse, PutDocumentTypeByIdMoveData, PutDocumentTypeByIdMoveResponse, GetDocumentTypeAllowedAtRootData, GetDocumentTypeAllowedAtRootResponse, PostDocumentTypeAvailableCompositionsData, PostDocumentTypeAvailableCompositionsResponse, GetDocumentTypeConfigurationResponse, PostDocumentTypeFolderData, PostDocumentTypeFolderResponse, GetDocumentTypeFolderByIdData, GetDocumentTypeFolderByIdResponse, DeleteDocumentTypeFolderByIdData, DeleteDocumentTypeFolderByIdResponse, PutDocumentTypeFolderByIdData, PutDocumentTypeFolderByIdResponse, PostDocumentTypeImportData, PostDocumentTypeImportResponse, GetItemDocumentTypeData, GetItemDocumentTypeResponse, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchResponse, GetTreeDocumentTypeAncestorsData, GetTreeDocumentTypeAncestorsResponse, GetTreeDocumentTypeChildrenData, GetTreeDocumentTypeChildrenResponse, GetTreeDocumentTypeRootData, GetTreeDocumentTypeRootResponse, GetDocumentVersionData, GetDocumentVersionResponse, GetDocumentVersionByIdData, GetDocumentVersionByIdResponse, PutDocumentVersionByIdPreventCleanupData, PutDocumentVersionByIdPreventCleanupResponse, PostDocumentVersionByIdRollbackData, PostDocumentVersionByIdRollbackResponse, PostDynamicRootQueryData, PostDynamicRootQueryResponse, GetDynamicRootStepsResponse, GetHealthCheckGroupData, GetHealthCheckGroupResponse, GetHealthCheckGroupByNameData, GetHealthCheckGroupByNameResponse, PostHealthCheckGroupByNameCheckData, PostHealthCheckGroupByNameCheckResponse, PostHealthCheckExecuteActionData, PostHealthCheckExecuteActionResponse, GetHelpData, GetHelpResponse, GetImagingResizeUrlsData, GetImagingResizeUrlsResponse, GetImportAnalyzeData, GetImportAnalyzeResponse, GetIndexerData, GetIndexerResponse, GetIndexerByIndexNameData, GetIndexerByIndexNameResponse, PostIndexerByIndexNameRebuildData, PostIndexerByIndexNameRebuildResponse, GetInstallSettingsResponse, PostInstallSetupData, PostInstallSetupResponse, PostInstallValidateDatabaseData, PostInstallValidateDatabaseResponse, GetItemLanguageData, GetItemLanguageResponse, GetItemLanguageDefaultResponse, GetLanguageData, GetLanguageResponse, PostLanguageData, PostLanguageResponse, GetLanguageByIsoCodeData, GetLanguageByIsoCodeResponse, DeleteLanguageByIsoCodeData, DeleteLanguageByIsoCodeResponse, PutLanguageByIsoCodeData, PutLanguageByIsoCodeResponse, GetLogViewerLevelData, GetLogViewerLevelResponse, GetLogViewerLevelCountData, GetLogViewerLevelCountResponse, GetLogViewerLogData, GetLogViewerLogResponse, GetLogViewerMessageTemplateData, GetLogViewerMessageTemplateResponse, GetLogViewerSavedSearchData, GetLogViewerSavedSearchResponse, PostLogViewerSavedSearchData, PostLogViewerSavedSearchResponse, GetLogViewerSavedSearchByNameData, GetLogViewerSavedSearchByNameResponse, DeleteLogViewerSavedSearchByNameData, DeleteLogViewerSavedSearchByNameResponse, GetLogViewerValidateLogsSizeData, GetLogViewerValidateLogsSizeResponse, GetManifestManifestResponse, GetManifestManifestPrivateResponse, GetManifestManifestPublicResponse, GetCollectionMediaData, GetCollectionMediaResponse, GetItemMediaData, GetItemMediaResponse, GetItemMediaSearchData, GetItemMediaSearchResponse, PostMediaData, PostMediaResponse, GetMediaByIdData, GetMediaByIdResponse, DeleteMediaByIdData, DeleteMediaByIdResponse, PutMediaByIdData, PutMediaByIdResponse, GetMediaByIdAuditLogData, GetMediaByIdAuditLogResponse, PutMediaByIdMoveData, PutMediaByIdMoveResponse, PutMediaByIdMoveToRecycleBinData, PutMediaByIdMoveToRecycleBinResponse, GetMediaByIdReferencedByData, GetMediaByIdReferencedByResponse, GetMediaByIdReferencedDescendantsData, GetMediaByIdReferencedDescendantsResponse, PutMediaByIdValidateData, PutMediaByIdValidateResponse, GetMediaAreReferencedData, GetMediaAreReferencedResponse, GetMediaConfigurationResponse, PutMediaSortData, PutMediaSortResponse, GetMediaUrlsData, GetMediaUrlsResponse, PostMediaValidateData, PostMediaValidateResponse, DeleteRecycleBinMediaResponse, DeleteRecycleBinMediaByIdData, DeleteRecycleBinMediaByIdResponse, GetRecycleBinMediaByIdOriginalParentData, GetRecycleBinMediaByIdOriginalParentResponse, PutRecycleBinMediaByIdRestoreData, PutRecycleBinMediaByIdRestoreResponse, GetRecycleBinMediaChildrenData, GetRecycleBinMediaChildrenResponse, GetRecycleBinMediaRootData, GetRecycleBinMediaRootResponse, GetTreeMediaAncestorsData, GetTreeMediaAncestorsResponse, GetTreeMediaChildrenData, GetTreeMediaChildrenResponse, GetTreeMediaRootData, GetTreeMediaRootResponse, GetItemMediaTypeData, GetItemMediaTypeResponse, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedResponse, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersResponse, GetItemMediaTypeSearchData, GetItemMediaTypeSearchResponse, PostMediaTypeData, PostMediaTypeResponse, GetMediaTypeByIdData, GetMediaTypeByIdResponse, DeleteMediaTypeByIdData, DeleteMediaTypeByIdResponse, PutMediaTypeByIdData, PutMediaTypeByIdResponse, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenResponse, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesResponse, PostMediaTypeByIdCopyData, PostMediaTypeByIdCopyResponse, GetMediaTypeByIdExportData, GetMediaTypeByIdExportResponse, PutMediaTypeByIdImportData, PutMediaTypeByIdImportResponse, PutMediaTypeByIdMoveData, PutMediaTypeByIdMoveResponse, GetMediaTypeAllowedAtRootData, GetMediaTypeAllowedAtRootResponse, PostMediaTypeAvailableCompositionsData, PostMediaTypeAvailableCompositionsResponse, GetMediaTypeConfigurationResponse, PostMediaTypeFolderData, PostMediaTypeFolderResponse, GetMediaTypeFolderByIdData, GetMediaTypeFolderByIdResponse, DeleteMediaTypeFolderByIdData, DeleteMediaTypeFolderByIdResponse, PutMediaTypeFolderByIdData, PutMediaTypeFolderByIdResponse, PostMediaTypeImportData, PostMediaTypeImportResponse, GetTreeMediaTypeAncestorsData, GetTreeMediaTypeAncestorsResponse, GetTreeMediaTypeChildrenData, GetTreeMediaTypeChildrenResponse, GetTreeMediaTypeRootData, GetTreeMediaTypeRootResponse, GetFilterMemberData, GetFilterMemberResponse, GetItemMemberData, GetItemMemberResponse, GetItemMemberSearchData, GetItemMemberSearchResponse, PostMemberData, PostMemberResponse, GetMemberByIdData, GetMemberByIdResponse, DeleteMemberByIdData, DeleteMemberByIdResponse, PutMemberByIdData, PutMemberByIdResponse, GetMemberByIdReferencedByData, GetMemberByIdReferencedByResponse, GetMemberByIdReferencedDescendantsData, GetMemberByIdReferencedDescendantsResponse, PutMemberByIdValidateData, PutMemberByIdValidateResponse, GetMemberAreReferencedData, GetMemberAreReferencedResponse, GetMemberConfigurationResponse, PostMemberValidateData, PostMemberValidateResponse, GetItemMemberGroupData, GetItemMemberGroupResponse, GetMemberGroupData, GetMemberGroupResponse, PostMemberGroupData, PostMemberGroupResponse, GetMemberGroupByIdData, GetMemberGroupByIdResponse, DeleteMemberGroupByIdData, DeleteMemberGroupByIdResponse, PutMemberGroupByIdData, PutMemberGroupByIdResponse, GetTreeMemberGroupRootData, GetTreeMemberGroupRootResponse, GetItemMemberTypeData, GetItemMemberTypeResponse, GetItemMemberTypeSearchData, GetItemMemberTypeSearchResponse, PostMemberTypeData, PostMemberTypeResponse, GetMemberTypeByIdData, GetMemberTypeByIdResponse, DeleteMemberTypeByIdData, DeleteMemberTypeByIdResponse, PutMemberTypeByIdData, PutMemberTypeByIdResponse, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesResponse, PostMemberTypeByIdCopyData, PostMemberTypeByIdCopyResponse, PostMemberTypeAvailableCompositionsData, PostMemberTypeAvailableCompositionsResponse, GetMemberTypeConfigurationResponse, GetTreeMemberTypeRootData, GetTreeMemberTypeRootResponse, PostModelsBuilderBuildResponse, GetModelsBuilderDashboardResponse, GetModelsBuilderStatusResponse, GetObjectTypesData, GetObjectTypesResponse, GetOembedQueryData, GetOembedQueryResponse, PostPackageByNameRunMigrationData, PostPackageByNameRunMigrationResponse, GetPackageConfigurationResponse, GetPackageCreatedData, GetPackageCreatedResponse, PostPackageCreatedData, PostPackageCreatedResponse, GetPackageCreatedByIdData, GetPackageCreatedByIdResponse, DeletePackageCreatedByIdData, DeletePackageCreatedByIdResponse, PutPackageCreatedByIdData, PutPackageCreatedByIdResponse, GetPackageCreatedByIdDownloadData, GetPackageCreatedByIdDownloadResponse, GetPackageMigrationStatusData, GetPackageMigrationStatusResponse, GetItemPartialViewData, GetItemPartialViewResponse, PostPartialViewData, PostPartialViewResponse, GetPartialViewByPathData, GetPartialViewByPathResponse, DeletePartialViewByPathData, DeletePartialViewByPathResponse, PutPartialViewByPathData, PutPartialViewByPathResponse, PutPartialViewByPathRenameData, PutPartialViewByPathRenameResponse, PostPartialViewFolderData, PostPartialViewFolderResponse, GetPartialViewFolderByPathData, GetPartialViewFolderByPathResponse, DeletePartialViewFolderByPathData, DeletePartialViewFolderByPathResponse, GetPartialViewSnippetData, GetPartialViewSnippetResponse, GetPartialViewSnippetByIdData, GetPartialViewSnippetByIdResponse, GetTreePartialViewAncestorsData, GetTreePartialViewAncestorsResponse, GetTreePartialViewChildrenData, GetTreePartialViewChildrenResponse, GetTreePartialViewRootData, GetTreePartialViewRootResponse, DeletePreviewResponse, PostPreviewResponse, GetProfilingStatusResponse, PutProfilingStatusData, PutProfilingStatusResponse, GetPropertyTypeIsUsedData, GetPropertyTypeIsUsedResponse, PostPublishedCacheRebuildResponse, GetPublishedCacheRebuildStatusResponse, PostPublishedCacheReloadResponse, GetRedirectManagementData, GetRedirectManagementResponse, GetRedirectManagementByIdData, GetRedirectManagementByIdResponse, DeleteRedirectManagementByIdData, DeleteRedirectManagementByIdResponse, GetRedirectManagementStatusResponse, PostRedirectManagementStatusData, PostRedirectManagementStatusResponse, GetRelationByRelationTypeIdData, GetRelationByRelationTypeIdResponse, GetItemRelationTypeData, GetItemRelationTypeResponse, GetRelationTypeData, GetRelationTypeResponse, GetRelationTypeByIdData, GetRelationTypeByIdResponse, GetItemScriptData, GetItemScriptResponse, PostScriptData, PostScriptResponse, GetScriptByPathData, GetScriptByPathResponse, DeleteScriptByPathData, DeleteScriptByPathResponse, PutScriptByPathData, PutScriptByPathResponse, PutScriptByPathRenameData, PutScriptByPathRenameResponse, PostScriptFolderData, PostScriptFolderResponse, GetScriptFolderByPathData, GetScriptFolderByPathResponse, DeleteScriptFolderByPathData, DeleteScriptFolderByPathResponse, GetTreeScriptAncestorsData, GetTreeScriptAncestorsResponse, GetTreeScriptChildrenData, GetTreeScriptChildrenResponse, GetTreeScriptRootData, GetTreeScriptRootResponse, GetSearcherData, GetSearcherResponse, GetSearcherBySearcherNameQueryData, GetSearcherBySearcherNameQueryResponse, GetSecurityConfigurationResponse, PostSecurityForgotPasswordData, PostSecurityForgotPasswordResponse, PostSecurityForgotPasswordResetData, PostSecurityForgotPasswordResetResponse, PostSecurityForgotPasswordVerifyData, PostSecurityForgotPasswordVerifyResponse, GetSegmentData, GetSegmentResponse, GetServerConfigurationResponse, GetServerInformationResponse, GetServerStatusResponse, GetServerTroubleshootingResponse, GetServerUpgradeCheckResponse, GetItemStaticFileData, GetItemStaticFileResponse, GetTreeStaticFileAncestorsData, GetTreeStaticFileAncestorsResponse, GetTreeStaticFileChildrenData, GetTreeStaticFileChildrenResponse, GetTreeStaticFileRootData, GetTreeStaticFileRootResponse, GetItemStylesheetData, GetItemStylesheetResponse, PostStylesheetData, PostStylesheetResponse, GetStylesheetByPathData, GetStylesheetByPathResponse, DeleteStylesheetByPathData, DeleteStylesheetByPathResponse, PutStylesheetByPathData, PutStylesheetByPathResponse, PutStylesheetByPathRenameData, PutStylesheetByPathRenameResponse, PostStylesheetFolderData, PostStylesheetFolderResponse, GetStylesheetFolderByPathData, GetStylesheetFolderByPathResponse, DeleteStylesheetFolderByPathData, DeleteStylesheetFolderByPathResponse, GetTreeStylesheetAncestorsData, GetTreeStylesheetAncestorsResponse, GetTreeStylesheetChildrenData, GetTreeStylesheetChildrenResponse, GetTreeStylesheetRootData, GetTreeStylesheetRootResponse, GetTagData, GetTagResponse, GetTelemetryData, GetTelemetryResponse, GetTelemetryLevelResponse, PostTelemetryLevelData, PostTelemetryLevelResponse, GetItemTemplateData, GetItemTemplateResponse, GetItemTemplateSearchData, GetItemTemplateSearchResponse, PostTemplateData, PostTemplateResponse, GetTemplateByIdData, GetTemplateByIdResponse, DeleteTemplateByIdData, DeleteTemplateByIdResponse, PutTemplateByIdData, PutTemplateByIdResponse, GetTemplateConfigurationResponse, PostTemplateQueryExecuteData, PostTemplateQueryExecuteResponse, GetTemplateQuerySettingsResponse, GetTreeTemplateAncestorsData, GetTreeTemplateAncestorsResponse, GetTreeTemplateChildrenData, GetTreeTemplateChildrenResponse, GetTreeTemplateRootData, GetTreeTemplateRootResponse, PostTemporaryFileData, PostTemporaryFileResponse, GetTemporaryFileByIdData, GetTemporaryFileByIdResponse, DeleteTemporaryFileByIdData, DeleteTemporaryFileByIdResponse, GetTemporaryFileConfigurationResponse, PostUpgradeAuthorizeResponse, GetUpgradeSettingsResponse, GetFilterUserData, GetFilterUserResponse, GetItemUserData, GetItemUserResponse, PostUserData, PostUserResponse, DeleteUserData, DeleteUserResponse, GetUserData, GetUserResponse, GetUserByIdData, GetUserByIdResponse, DeleteUserByIdData, DeleteUserByIdResponse, PutUserByIdData, PutUserByIdResponse, GetUserById2FaData, GetUserById2FaResponse, DeleteUserById2FaByProviderNameData, DeleteUserById2FaByProviderNameResponse, GetUserByIdCalculateStartNodesData, GetUserByIdCalculateStartNodesResponse, PostUserByIdChangePasswordData, PostUserByIdChangePasswordResponse, PostUserByIdClientCredentialsData, PostUserByIdClientCredentialsResponse, GetUserByIdClientCredentialsData, GetUserByIdClientCredentialsResponse, DeleteUserByIdClientCredentialsByClientIdData, DeleteUserByIdClientCredentialsByClientIdResponse, PostUserByIdResetPasswordData, PostUserByIdResetPasswordResponse, DeleteUserAvatarByIdData, DeleteUserAvatarByIdResponse, PostUserAvatarByIdData, PostUserAvatarByIdResponse, GetUserConfigurationResponse, GetUserCurrentResponse, GetUserCurrent2FaResponse, DeleteUserCurrent2FaByProviderNameData, DeleteUserCurrent2FaByProviderNameResponse, PostUserCurrent2FaByProviderNameData, PostUserCurrent2FaByProviderNameResponse, GetUserCurrent2FaByProviderNameData, GetUserCurrent2FaByProviderNameResponse, PostUserCurrentAvatarData, PostUserCurrentAvatarResponse, PostUserCurrentChangePasswordData, PostUserCurrentChangePasswordResponse, GetUserCurrentConfigurationResponse, GetUserCurrentLoginProvidersResponse, GetUserCurrentPermissionsData, GetUserCurrentPermissionsResponse, GetUserCurrentPermissionsDocumentData, GetUserCurrentPermissionsDocumentResponse, GetUserCurrentPermissionsMediaData, GetUserCurrentPermissionsMediaResponse, PostUserDisableData, PostUserDisableResponse, PostUserEnableData, PostUserEnableResponse, PostUserInviteData, PostUserInviteResponse, PostUserInviteCreatePasswordData, PostUserInviteCreatePasswordResponse, PostUserInviteResendData, PostUserInviteResendResponse, PostUserInviteVerifyData, PostUserInviteVerifyResponse, PostUserSetUserGroupsData, PostUserSetUserGroupsResponse, PostUserUnlockData, PostUserUnlockResponse, PostUserDataData, PostUserDataResponse, GetUserDataData, GetUserDataResponse, PutUserDataData, PutUserDataResponse, GetUserDataByIdData, GetUserDataByIdResponse, GetFilterUserGroupData, GetFilterUserGroupResponse, GetItemUserGroupData, GetItemUserGroupResponse, DeleteUserGroupData, DeleteUserGroupResponse, PostUserGroupData, PostUserGroupResponse, GetUserGroupData, GetUserGroupResponse, GetUserGroupByIdData, GetUserGroupByIdResponse, DeleteUserGroupByIdData, DeleteUserGroupByIdResponse, PutUserGroupByIdData, PutUserGroupByIdResponse, DeleteUserGroupByIdUsersData, DeleteUserGroupByIdUsersResponse, PostUserGroupByIdUsersData, PostUserGroupByIdUsersResponse, GetItemWebhookData, GetItemWebhookResponse, GetWebhookData, GetWebhookResponse, PostWebhookData, PostWebhookResponse, GetWebhookByIdData, GetWebhookByIdResponse, DeleteWebhookByIdData, DeleteWebhookByIdResponse, PutWebhookByIdData, PutWebhookByIdResponse, GetWebhookByIdLogsData, GetWebhookByIdLogsResponse, GetWebhookEventsData, GetWebhookEventsResponse, GetWebhookLogsData, GetWebhookLogsResponse } from './types.gen'; +import type { GetCultureData, GetCultureResponse, PostDataTypeData, PostDataTypeResponse, GetDataTypeByIdData, GetDataTypeByIdResponse, DeleteDataTypeByIdData, DeleteDataTypeByIdResponse, PutDataTypeByIdData, PutDataTypeByIdResponse, PostDataTypeByIdCopyData, PostDataTypeByIdCopyResponse, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedResponse, PutDataTypeByIdMoveData, PutDataTypeByIdMoveResponse, GetDataTypeByIdReferencesData, GetDataTypeByIdReferencesResponse, GetDataTypeConfigurationResponse, PostDataTypeFolderData, PostDataTypeFolderResponse, GetDataTypeFolderByIdData, GetDataTypeFolderByIdResponse, DeleteDataTypeFolderByIdData, DeleteDataTypeFolderByIdResponse, PutDataTypeFolderByIdData, PutDataTypeFolderByIdResponse, GetFilterDataTypeData, GetFilterDataTypeResponse, GetItemDataTypeData, GetItemDataTypeResponse, GetItemDataTypeSearchData, GetItemDataTypeSearchResponse, GetTreeDataTypeAncestorsData, GetTreeDataTypeAncestorsResponse, GetTreeDataTypeChildrenData, GetTreeDataTypeChildrenResponse, GetTreeDataTypeRootData, GetTreeDataTypeRootResponse, GetDictionaryData, GetDictionaryResponse, PostDictionaryData, PostDictionaryResponse, GetDictionaryByIdData, GetDictionaryByIdResponse, DeleteDictionaryByIdData, DeleteDictionaryByIdResponse, PutDictionaryByIdData, PutDictionaryByIdResponse, GetDictionaryByIdExportData, GetDictionaryByIdExportResponse, PutDictionaryByIdMoveData, PutDictionaryByIdMoveResponse, PostDictionaryImportData, PostDictionaryImportResponse, GetItemDictionaryData, GetItemDictionaryResponse, GetTreeDictionaryAncestorsData, GetTreeDictionaryAncestorsResponse, GetTreeDictionaryChildrenData, GetTreeDictionaryChildrenResponse, GetTreeDictionaryRootData, GetTreeDictionaryRootResponse, GetCollectionDocumentByIdData, GetCollectionDocumentByIdResponse, PostDocumentData, PostDocumentResponse, GetDocumentByIdData, GetDocumentByIdResponse, DeleteDocumentByIdData, DeleteDocumentByIdResponse, PutDocumentByIdData, PutDocumentByIdResponse, GetDocumentByIdAuditLogData, GetDocumentByIdAuditLogResponse, PostDocumentByIdCopyData, PostDocumentByIdCopyResponse, GetDocumentByIdDomainsData, GetDocumentByIdDomainsResponse, PutDocumentByIdDomainsData, PutDocumentByIdDomainsResponse, PutDocumentByIdMoveData, PutDocumentByIdMoveResponse, PutDocumentByIdMoveToRecycleBinData, PutDocumentByIdMoveToRecycleBinResponse, GetDocumentByIdNotificationsData, GetDocumentByIdNotificationsResponse, PutDocumentByIdNotificationsData, PutDocumentByIdNotificationsResponse, PostDocumentByIdPublicAccessData, PostDocumentByIdPublicAccessResponse, DeleteDocumentByIdPublicAccessData, DeleteDocumentByIdPublicAccessResponse, GetDocumentByIdPublicAccessData, GetDocumentByIdPublicAccessResponse, PutDocumentByIdPublicAccessData, PutDocumentByIdPublicAccessResponse, PutDocumentByIdPublishData, PutDocumentByIdPublishResponse, PutDocumentByIdPublishWithDescendantsData, PutDocumentByIdPublishWithDescendantsResponse, GetDocumentByIdPublishWithDescendantsResultByTaskIdData, GetDocumentByIdPublishWithDescendantsResultByTaskIdResponse, GetDocumentByIdPublishedData, GetDocumentByIdPublishedResponse, GetDocumentByIdReferencedByData, GetDocumentByIdReferencedByResponse, GetDocumentByIdReferencedDescendantsData, GetDocumentByIdReferencedDescendantsResponse, PutDocumentByIdUnpublishData, PutDocumentByIdUnpublishResponse, PutDocumentByIdValidateData, PutDocumentByIdValidateResponse, PutUmbracoManagementApiV11DocumentByIdValidate11Data, PutUmbracoManagementApiV11DocumentByIdValidate11Response, GetDocumentAreReferencedData, GetDocumentAreReferencedResponse, GetDocumentConfigurationResponse, PutDocumentSortData, PutDocumentSortResponse, GetDocumentUrlsData, GetDocumentUrlsResponse, PostDocumentValidateData, PostDocumentValidateResponse, GetItemDocumentData, GetItemDocumentResponse, GetItemDocumentSearchData, GetItemDocumentSearchResponse, DeleteRecycleBinDocumentResponse, DeleteRecycleBinDocumentByIdData, DeleteRecycleBinDocumentByIdResponse, GetRecycleBinDocumentByIdOriginalParentData, GetRecycleBinDocumentByIdOriginalParentResponse, PutRecycleBinDocumentByIdRestoreData, PutRecycleBinDocumentByIdRestoreResponse, GetRecycleBinDocumentChildrenData, GetRecycleBinDocumentChildrenResponse, GetRecycleBinDocumentRootData, GetRecycleBinDocumentRootResponse, GetTreeDocumentAncestorsData, GetTreeDocumentAncestorsResponse, GetTreeDocumentChildrenData, GetTreeDocumentChildrenResponse, GetTreeDocumentRootData, GetTreeDocumentRootResponse, PostDocumentBlueprintData, PostDocumentBlueprintResponse, GetDocumentBlueprintByIdData, GetDocumentBlueprintByIdResponse, DeleteDocumentBlueprintByIdData, DeleteDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdData, PutDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdMoveData, PutDocumentBlueprintByIdMoveResponse, PostDocumentBlueprintFolderData, PostDocumentBlueprintFolderResponse, GetDocumentBlueprintFolderByIdData, GetDocumentBlueprintFolderByIdResponse, DeleteDocumentBlueprintFolderByIdData, DeleteDocumentBlueprintFolderByIdResponse, PutDocumentBlueprintFolderByIdData, PutDocumentBlueprintFolderByIdResponse, PostDocumentBlueprintFromDocumentData, PostDocumentBlueprintFromDocumentResponse, GetItemDocumentBlueprintData, GetItemDocumentBlueprintResponse, GetTreeDocumentBlueprintAncestorsData, GetTreeDocumentBlueprintAncestorsResponse, GetTreeDocumentBlueprintChildrenData, GetTreeDocumentBlueprintChildrenResponse, GetTreeDocumentBlueprintRootData, GetTreeDocumentBlueprintRootResponse, PostDocumentTypeData, PostDocumentTypeResponse, GetDocumentTypeByIdData, GetDocumentTypeByIdResponse, DeleteDocumentTypeByIdData, DeleteDocumentTypeByIdResponse, PutDocumentTypeByIdData, PutDocumentTypeByIdResponse, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenResponse, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintResponse, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesResponse, PostDocumentTypeByIdCopyData, PostDocumentTypeByIdCopyResponse, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportResponse, PutDocumentTypeByIdImportData, PutDocumentTypeByIdImportResponse, PutDocumentTypeByIdMoveData, PutDocumentTypeByIdMoveResponse, GetDocumentTypeAllowedAtRootData, GetDocumentTypeAllowedAtRootResponse, PostDocumentTypeAvailableCompositionsData, PostDocumentTypeAvailableCompositionsResponse, GetDocumentTypeConfigurationResponse, PostDocumentTypeFolderData, PostDocumentTypeFolderResponse, GetDocumentTypeFolderByIdData, GetDocumentTypeFolderByIdResponse, DeleteDocumentTypeFolderByIdData, DeleteDocumentTypeFolderByIdResponse, PutDocumentTypeFolderByIdData, PutDocumentTypeFolderByIdResponse, PostDocumentTypeImportData, PostDocumentTypeImportResponse, GetItemDocumentTypeData, GetItemDocumentTypeResponse, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchResponse, GetTreeDocumentTypeAncestorsData, GetTreeDocumentTypeAncestorsResponse, GetTreeDocumentTypeChildrenData, GetTreeDocumentTypeChildrenResponse, GetTreeDocumentTypeRootData, GetTreeDocumentTypeRootResponse, GetDocumentVersionData, GetDocumentVersionResponse, GetDocumentVersionByIdData, GetDocumentVersionByIdResponse, PutDocumentVersionByIdPreventCleanupData, PutDocumentVersionByIdPreventCleanupResponse, PostDocumentVersionByIdRollbackData, PostDocumentVersionByIdRollbackResponse, PostDynamicRootQueryData, PostDynamicRootQueryResponse, GetDynamicRootStepsResponse, GetHealthCheckGroupData, GetHealthCheckGroupResponse, GetHealthCheckGroupByNameData, GetHealthCheckGroupByNameResponse, PostHealthCheckGroupByNameCheckData, PostHealthCheckGroupByNameCheckResponse, PostHealthCheckExecuteActionData, PostHealthCheckExecuteActionResponse, GetHelpData, GetHelpResponse, GetImagingResizeUrlsData, GetImagingResizeUrlsResponse, GetImportAnalyzeData, GetImportAnalyzeResponse, GetIndexerData, GetIndexerResponse, GetIndexerByIndexNameData, GetIndexerByIndexNameResponse, PostIndexerByIndexNameRebuildData, PostIndexerByIndexNameRebuildResponse, GetInstallSettingsResponse, PostInstallSetupData, PostInstallSetupResponse, PostInstallValidateDatabaseData, PostInstallValidateDatabaseResponse, GetItemLanguageData, GetItemLanguageResponse, GetItemLanguageDefaultResponse, GetLanguageData, GetLanguageResponse, PostLanguageData, PostLanguageResponse, GetLanguageByIsoCodeData, GetLanguageByIsoCodeResponse, DeleteLanguageByIsoCodeData, DeleteLanguageByIsoCodeResponse, PutLanguageByIsoCodeData, PutLanguageByIsoCodeResponse, GetLogViewerLevelData, GetLogViewerLevelResponse, GetLogViewerLevelCountData, GetLogViewerLevelCountResponse, GetLogViewerLogData, GetLogViewerLogResponse, GetLogViewerMessageTemplateData, GetLogViewerMessageTemplateResponse, GetLogViewerSavedSearchData, GetLogViewerSavedSearchResponse, PostLogViewerSavedSearchData, PostLogViewerSavedSearchResponse, GetLogViewerSavedSearchByNameData, GetLogViewerSavedSearchByNameResponse, DeleteLogViewerSavedSearchByNameData, DeleteLogViewerSavedSearchByNameResponse, GetLogViewerValidateLogsSizeData, GetLogViewerValidateLogsSizeResponse, GetManifestManifestResponse, GetManifestManifestPrivateResponse, GetManifestManifestPublicResponse, GetCollectionMediaData, GetCollectionMediaResponse, GetItemMediaData, GetItemMediaResponse, GetItemMediaSearchData, GetItemMediaSearchResponse, PostMediaData, PostMediaResponse, GetMediaByIdData, GetMediaByIdResponse, DeleteMediaByIdData, DeleteMediaByIdResponse, PutMediaByIdData, PutMediaByIdResponse, GetMediaByIdAuditLogData, GetMediaByIdAuditLogResponse, PutMediaByIdMoveData, PutMediaByIdMoveResponse, PutMediaByIdMoveToRecycleBinData, PutMediaByIdMoveToRecycleBinResponse, GetMediaByIdReferencedByData, GetMediaByIdReferencedByResponse, GetMediaByIdReferencedDescendantsData, GetMediaByIdReferencedDescendantsResponse, PutMediaByIdValidateData, PutMediaByIdValidateResponse, GetMediaAreReferencedData, GetMediaAreReferencedResponse, GetMediaConfigurationResponse, PutMediaSortData, PutMediaSortResponse, GetMediaUrlsData, GetMediaUrlsResponse, PostMediaValidateData, PostMediaValidateResponse, DeleteRecycleBinMediaResponse, DeleteRecycleBinMediaByIdData, DeleteRecycleBinMediaByIdResponse, GetRecycleBinMediaByIdOriginalParentData, GetRecycleBinMediaByIdOriginalParentResponse, PutRecycleBinMediaByIdRestoreData, PutRecycleBinMediaByIdRestoreResponse, GetRecycleBinMediaChildrenData, GetRecycleBinMediaChildrenResponse, GetRecycleBinMediaRootData, GetRecycleBinMediaRootResponse, GetTreeMediaAncestorsData, GetTreeMediaAncestorsResponse, GetTreeMediaChildrenData, GetTreeMediaChildrenResponse, GetTreeMediaRootData, GetTreeMediaRootResponse, GetItemMediaTypeData, GetItemMediaTypeResponse, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedResponse, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersResponse, GetItemMediaTypeSearchData, GetItemMediaTypeSearchResponse, PostMediaTypeData, PostMediaTypeResponse, GetMediaTypeByIdData, GetMediaTypeByIdResponse, DeleteMediaTypeByIdData, DeleteMediaTypeByIdResponse, PutMediaTypeByIdData, PutMediaTypeByIdResponse, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenResponse, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesResponse, PostMediaTypeByIdCopyData, PostMediaTypeByIdCopyResponse, GetMediaTypeByIdExportData, GetMediaTypeByIdExportResponse, PutMediaTypeByIdImportData, PutMediaTypeByIdImportResponse, PutMediaTypeByIdMoveData, PutMediaTypeByIdMoveResponse, GetMediaTypeAllowedAtRootData, GetMediaTypeAllowedAtRootResponse, PostMediaTypeAvailableCompositionsData, PostMediaTypeAvailableCompositionsResponse, GetMediaTypeConfigurationResponse, PostMediaTypeFolderData, PostMediaTypeFolderResponse, GetMediaTypeFolderByIdData, GetMediaTypeFolderByIdResponse, DeleteMediaTypeFolderByIdData, DeleteMediaTypeFolderByIdResponse, PutMediaTypeFolderByIdData, PutMediaTypeFolderByIdResponse, PostMediaTypeImportData, PostMediaTypeImportResponse, GetTreeMediaTypeAncestorsData, GetTreeMediaTypeAncestorsResponse, GetTreeMediaTypeChildrenData, GetTreeMediaTypeChildrenResponse, GetTreeMediaTypeRootData, GetTreeMediaTypeRootResponse, GetFilterMemberData, GetFilterMemberResponse, GetItemMemberData, GetItemMemberResponse, GetItemMemberSearchData, GetItemMemberSearchResponse, PostMemberData, PostMemberResponse, GetMemberByIdData, GetMemberByIdResponse, DeleteMemberByIdData, DeleteMemberByIdResponse, PutMemberByIdData, PutMemberByIdResponse, GetMemberByIdReferencedByData, GetMemberByIdReferencedByResponse, GetMemberByIdReferencedDescendantsData, GetMemberByIdReferencedDescendantsResponse, PutMemberByIdValidateData, PutMemberByIdValidateResponse, GetMemberAreReferencedData, GetMemberAreReferencedResponse, GetMemberConfigurationResponse, PostMemberValidateData, PostMemberValidateResponse, GetItemMemberGroupData, GetItemMemberGroupResponse, GetMemberGroupData, GetMemberGroupResponse, PostMemberGroupData, PostMemberGroupResponse, GetMemberGroupByIdData, GetMemberGroupByIdResponse, DeleteMemberGroupByIdData, DeleteMemberGroupByIdResponse, PutMemberGroupByIdData, PutMemberGroupByIdResponse, GetTreeMemberGroupRootData, GetTreeMemberGroupRootResponse, GetItemMemberTypeData, GetItemMemberTypeResponse, GetItemMemberTypeSearchData, GetItemMemberTypeSearchResponse, PostMemberTypeData, PostMemberTypeResponse, GetMemberTypeByIdData, GetMemberTypeByIdResponse, DeleteMemberTypeByIdData, DeleteMemberTypeByIdResponse, PutMemberTypeByIdData, PutMemberTypeByIdResponse, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesResponse, PostMemberTypeByIdCopyData, PostMemberTypeByIdCopyResponse, PostMemberTypeAvailableCompositionsData, PostMemberTypeAvailableCompositionsResponse, GetMemberTypeConfigurationResponse, GetTreeMemberTypeRootData, GetTreeMemberTypeRootResponse, PostModelsBuilderBuildResponse, GetModelsBuilderDashboardResponse, GetModelsBuilderStatusResponse, GetObjectTypesData, GetObjectTypesResponse, GetOembedQueryData, GetOembedQueryResponse, PostPackageByNameRunMigrationData, PostPackageByNameRunMigrationResponse, GetPackageConfigurationResponse, GetPackageCreatedData, GetPackageCreatedResponse, PostPackageCreatedData, PostPackageCreatedResponse, GetPackageCreatedByIdData, GetPackageCreatedByIdResponse, DeletePackageCreatedByIdData, DeletePackageCreatedByIdResponse, PutPackageCreatedByIdData, PutPackageCreatedByIdResponse, GetPackageCreatedByIdDownloadData, GetPackageCreatedByIdDownloadResponse, GetPackageMigrationStatusData, GetPackageMigrationStatusResponse, GetItemPartialViewData, GetItemPartialViewResponse, PostPartialViewData, PostPartialViewResponse, GetPartialViewByPathData, GetPartialViewByPathResponse, DeletePartialViewByPathData, DeletePartialViewByPathResponse, PutPartialViewByPathData, PutPartialViewByPathResponse, PutPartialViewByPathRenameData, PutPartialViewByPathRenameResponse, PostPartialViewFolderData, PostPartialViewFolderResponse, GetPartialViewFolderByPathData, GetPartialViewFolderByPathResponse, DeletePartialViewFolderByPathData, DeletePartialViewFolderByPathResponse, GetPartialViewSnippetData, GetPartialViewSnippetResponse, GetPartialViewSnippetByIdData, GetPartialViewSnippetByIdResponse, GetTreePartialViewAncestorsData, GetTreePartialViewAncestorsResponse, GetTreePartialViewChildrenData, GetTreePartialViewChildrenResponse, GetTreePartialViewRootData, GetTreePartialViewRootResponse, DeletePreviewResponse, PostPreviewResponse, GetProfilingStatusResponse, PutProfilingStatusData, PutProfilingStatusResponse, GetPropertyTypeIsUsedData, GetPropertyTypeIsUsedResponse, PostPublishedCacheRebuildResponse, GetPublishedCacheRebuildStatusResponse, PostPublishedCacheReloadResponse, GetRedirectManagementData, GetRedirectManagementResponse, GetRedirectManagementByIdData, GetRedirectManagementByIdResponse, DeleteRedirectManagementByIdData, DeleteRedirectManagementByIdResponse, GetRedirectManagementStatusResponse, PostRedirectManagementStatusData, PostRedirectManagementStatusResponse, GetRelationByRelationTypeIdData, GetRelationByRelationTypeIdResponse, GetItemRelationTypeData, GetItemRelationTypeResponse, GetRelationTypeData, GetRelationTypeResponse, GetRelationTypeByIdData, GetRelationTypeByIdResponse, GetItemScriptData, GetItemScriptResponse, PostScriptData, PostScriptResponse, GetScriptByPathData, GetScriptByPathResponse, DeleteScriptByPathData, DeleteScriptByPathResponse, PutScriptByPathData, PutScriptByPathResponse, PutScriptByPathRenameData, PutScriptByPathRenameResponse, PostScriptFolderData, PostScriptFolderResponse, GetScriptFolderByPathData, GetScriptFolderByPathResponse, DeleteScriptFolderByPathData, DeleteScriptFolderByPathResponse, GetTreeScriptAncestorsData, GetTreeScriptAncestorsResponse, GetTreeScriptChildrenData, GetTreeScriptChildrenResponse, GetTreeScriptRootData, GetTreeScriptRootResponse, GetSearcherData, GetSearcherResponse, GetSearcherBySearcherNameQueryData, GetSearcherBySearcherNameQueryResponse, GetSecurityConfigurationResponse, PostSecurityForgotPasswordData, PostSecurityForgotPasswordResponse, PostSecurityForgotPasswordResetData, PostSecurityForgotPasswordResetResponse, PostSecurityForgotPasswordVerifyData, PostSecurityForgotPasswordVerifyResponse, GetSegmentData, GetSegmentResponse, GetServerConfigurationResponse, GetServerInformationResponse, GetServerStatusResponse, GetServerTroubleshootingResponse, GetServerUpgradeCheckResponse, GetItemStaticFileData, GetItemStaticFileResponse, GetTreeStaticFileAncestorsData, GetTreeStaticFileAncestorsResponse, GetTreeStaticFileChildrenData, GetTreeStaticFileChildrenResponse, GetTreeStaticFileRootData, GetTreeStaticFileRootResponse, GetItemStylesheetData, GetItemStylesheetResponse, PostStylesheetData, PostStylesheetResponse, GetStylesheetByPathData, GetStylesheetByPathResponse, DeleteStylesheetByPathData, DeleteStylesheetByPathResponse, PutStylesheetByPathData, PutStylesheetByPathResponse, PutStylesheetByPathRenameData, PutStylesheetByPathRenameResponse, PostStylesheetFolderData, PostStylesheetFolderResponse, GetStylesheetFolderByPathData, GetStylesheetFolderByPathResponse, DeleteStylesheetFolderByPathData, DeleteStylesheetFolderByPathResponse, GetTreeStylesheetAncestorsData, GetTreeStylesheetAncestorsResponse, GetTreeStylesheetChildrenData, GetTreeStylesheetChildrenResponse, GetTreeStylesheetRootData, GetTreeStylesheetRootResponse, GetTagData, GetTagResponse, GetTelemetryData, GetTelemetryResponse, GetTelemetryLevelResponse, PostTelemetryLevelData, PostTelemetryLevelResponse, GetItemTemplateData, GetItemTemplateResponse, GetItemTemplateSearchData, GetItemTemplateSearchResponse, PostTemplateData, PostTemplateResponse, GetTemplateByIdData, GetTemplateByIdResponse, DeleteTemplateByIdData, DeleteTemplateByIdResponse, PutTemplateByIdData, PutTemplateByIdResponse, GetTemplateConfigurationResponse, PostTemplateQueryExecuteData, PostTemplateQueryExecuteResponse, GetTemplateQuerySettingsResponse, GetTreeTemplateAncestorsData, GetTreeTemplateAncestorsResponse, GetTreeTemplateChildrenData, GetTreeTemplateChildrenResponse, GetTreeTemplateRootData, GetTreeTemplateRootResponse, PostTemporaryFileData, PostTemporaryFileResponse, GetTemporaryFileByIdData, GetTemporaryFileByIdResponse, DeleteTemporaryFileByIdData, DeleteTemporaryFileByIdResponse, GetTemporaryFileConfigurationResponse, PostUpgradeAuthorizeResponse, GetUpgradeSettingsResponse, GetFilterUserData, GetFilterUserResponse, GetItemUserData, GetItemUserResponse, PostUserData, PostUserResponse, DeleteUserData, DeleteUserResponse, GetUserData, GetUserResponse, GetUserByIdData, GetUserByIdResponse, DeleteUserByIdData, DeleteUserByIdResponse, PutUserByIdData, PutUserByIdResponse, GetUserById2FaData, GetUserById2FaResponse, DeleteUserById2FaByProviderNameData, DeleteUserById2FaByProviderNameResponse, GetUserByIdCalculateStartNodesData, GetUserByIdCalculateStartNodesResponse, PostUserByIdChangePasswordData, PostUserByIdChangePasswordResponse, PostUserByIdClientCredentialsData, PostUserByIdClientCredentialsResponse, GetUserByIdClientCredentialsData, GetUserByIdClientCredentialsResponse, DeleteUserByIdClientCredentialsByClientIdData, DeleteUserByIdClientCredentialsByClientIdResponse, PostUserByIdResetPasswordData, PostUserByIdResetPasswordResponse, DeleteUserAvatarByIdData, DeleteUserAvatarByIdResponse, PostUserAvatarByIdData, PostUserAvatarByIdResponse, GetUserConfigurationResponse, GetUserCurrentResponse, GetUserCurrent2FaResponse, DeleteUserCurrent2FaByProviderNameData, DeleteUserCurrent2FaByProviderNameResponse, PostUserCurrent2FaByProviderNameData, PostUserCurrent2FaByProviderNameResponse, GetUserCurrent2FaByProviderNameData, GetUserCurrent2FaByProviderNameResponse, PostUserCurrentAvatarData, PostUserCurrentAvatarResponse, PostUserCurrentChangePasswordData, PostUserCurrentChangePasswordResponse, GetUserCurrentConfigurationResponse, GetUserCurrentLoginProvidersResponse, GetUserCurrentPermissionsData, GetUserCurrentPermissionsResponse, GetUserCurrentPermissionsDocumentData, GetUserCurrentPermissionsDocumentResponse, GetUserCurrentPermissionsMediaData, GetUserCurrentPermissionsMediaResponse, PostUserDisableData, PostUserDisableResponse, PostUserEnableData, PostUserEnableResponse, PostUserInviteData, PostUserInviteResponse, PostUserInviteCreatePasswordData, PostUserInviteCreatePasswordResponse, PostUserInviteResendData, PostUserInviteResendResponse, PostUserInviteVerifyData, PostUserInviteVerifyResponse, PostUserSetUserGroupsData, PostUserSetUserGroupsResponse, PostUserUnlockData, PostUserUnlockResponse, PostUserDataData, PostUserDataResponse, GetUserDataData, GetUserDataResponse, PutUserDataData, PutUserDataResponse, GetUserDataByIdData, GetUserDataByIdResponse, GetFilterUserGroupData, GetFilterUserGroupResponse, GetItemUserGroupData, GetItemUserGroupResponse, DeleteUserGroupData, DeleteUserGroupResponse, PostUserGroupData, PostUserGroupResponse, GetUserGroupData, GetUserGroupResponse, GetUserGroupByIdData, GetUserGroupByIdResponse, DeleteUserGroupByIdData, DeleteUserGroupByIdResponse, PutUserGroupByIdData, PutUserGroupByIdResponse, DeleteUserGroupByIdUsersData, DeleteUserGroupByIdUsersResponse, PostUserGroupByIdUsersData, PostUserGroupByIdUsersResponse, GetItemWebhookData, GetItemWebhookResponse, GetWebhookData, GetWebhookResponse, PostWebhookData, PostWebhookResponse, GetWebhookByIdData, GetWebhookByIdResponse, DeleteWebhookByIdData, DeleteWebhookByIdResponse, PutWebhookByIdData, PutWebhookByIdResponse, GetWebhookByIdLogsData, GetWebhookByIdLogsResponse, GetWebhookEventsData, GetWebhookEventsResponse, GetWebhookLogsData, GetWebhookLogsResponse } from './types.gen'; export class CultureService { /** @@ -1192,7 +1192,7 @@ export class DocumentService { * @param data The data for the request. * @param data.id * @param data.requestBody - * @returns string OK + * @returns unknown OK * @throws ApiError */ public static putDocumentByIdPublishWithDescendants(data: PutDocumentByIdPublishWithDescendantsData): CancelablePromise { @@ -1204,7 +1204,30 @@ export class DocumentService { }, body: data.requestBody, mediaType: 'application/json', - responseHeader: 'Umb-Notifications', + errors: { + 400: 'Bad Request', + 401: 'The resource is protected and requires an authentication token', + 403: 'The authenticated user does not have access to this resource', + 404: 'Not Found' + } + }); + } + + /** + * @param data The data for the request. + * @param data.id + * @param data.taskId + * @returns unknown OK + * @throws ApiError + */ + public static getDocumentByIdPublishWithDescendantsResultByTaskId(data: GetDocumentByIdPublishWithDescendantsResultByTaskIdData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/document/{id}/publish-with-descendants/result/{taskId}', + path: { + id: data.id, + taskId: data.taskId + }, errors: { 400: 'Bad Request', 401: 'The resource is protected and requires an authentication token', diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts index a1a97aa80d..d5d0ba3b42 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts @@ -2071,6 +2071,11 @@ export type PublishedDocumentResponseModel = { isTrashed: boolean; }; +export type PublishWithDescendantsResultModel = { + taskId: string; + isComplete: boolean; +}; + export type RebuildStatusModel = { isRebuilding: boolean; }; @@ -3286,7 +3291,14 @@ export type PutDocumentByIdPublishWithDescendantsData = { requestBody?: (PublishDocumentWithDescendantsRequestModel); }; -export type PutDocumentByIdPublishWithDescendantsResponse = (string); +export type PutDocumentByIdPublishWithDescendantsResponse = ((PublishWithDescendantsResultModel)); + +export type GetDocumentByIdPublishWithDescendantsResultByTaskIdData = { + id: string; + taskId: string; +}; + +export type GetDocumentByIdPublishWithDescendantsResultByTaskIdResponse = ((PublishWithDescendantsResultModel)); export type GetDocumentByIdPublishedData = { id: string; 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 f0df4391b9..44fde410eb 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 @@ -78,6 +78,10 @@ export class UmbDocumentPublishingRepository extends UmbRepositoryBase { if (!variantIds) throw new Error('variant IDs are missing'); await this.#init; + const notification = { data: { message: `Document and descendants submitted for publishing...` } }; + // TODO: Move this to the calling workspace context [JOV] + this.#notificationContext?.peek('positive', notification); + const { error } = await this.#publishingDataSource.publishWithDescendants( id, variantIds, 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 add5a266fa..36ab0bad3c 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 @@ -106,10 +106,35 @@ export class UmbDocumentPublishingServerDataSource { includeUnpublishedDescendants, }; - return tryExecuteAndNotify( + // Initiate the publish descendants task and get back a task Id. + const { data, error } = await tryExecuteAndNotify( this.#host, DocumentService.putDocumentByIdPublishWithDescendants({ id: unique, requestBody }), ); + + if (error || !data) { + return { error }; + } + + const taskId = data.taskId; + + // Poll until we know publishing is finished, then return the result. + let isFirstPoll = true; + while (true) { + await new Promise((resolve) => setTimeout(resolve, isFirstPoll ? 1000 : 5000)); + isFirstPoll = false; + const { data, error } = await tryExecuteAndNotify( + this.#host, + DocumentService.getDocumentByIdPublishWithDescendantsResultByTaskId({ id: unique, taskId })); + if (error || !data) { + return { error }; + } + + if (data.isComplete) { + return { error: null }; + } + + } } /** diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs index 5951af2bb5..42b8f6297b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs @@ -51,7 +51,7 @@ public partial class ContentPublishingServiceTests [TestCase(PublishBranchFilter.All)] public async Task Publish_Branch_Does_Not_Publish_Unpublished_Children_Unless_Instructed_To(PublishBranchFilter publishBranchFilter) { - var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, publishBranchFilter, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, publishBranchFilter, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); @@ -81,7 +81,7 @@ public partial class ContentPublishingServiceTests ContentService.Save(subpage2Subpage, -1); VerifyIsNotPublished(Subpage2.Key); - var result = await ContentPublishingService.PublishBranchAsync(Subpage2.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(Subpage2.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); AssertBranchResultSuccess(result.Result, Subpage2.Key, subpage2Subpage.Key); @@ -192,7 +192,7 @@ public partial class ContentPublishingServiceTests child.SetValue("title", "DA child title", culture: langDa.IsoCode); ContentService.Save(child); - var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode, langDa.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode, langDa.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); AssertBranchResultSuccess(result.Result, root.Key, child.Key); @@ -254,7 +254,7 @@ public partial class ContentPublishingServiceTests child.SetValue("title", "DA child title", culture: langDa.IsoCode); ContentService.Save(child); - var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); AssertBranchResultSuccess(result.Result, root.Key, child.Key); @@ -293,7 +293,7 @@ public partial class ContentPublishingServiceTests child.SetValue("title", "DA child title", culture: langDa.IsoCode); ContentService.Save(child); - var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); AssertBranchResultSuccess(result.Result, root.Key, child.Key); @@ -416,7 +416,7 @@ public partial class ContentPublishingServiceTests public async Task Cannot_Publish_Branch_Of_Non_Existing_Content() { var key = Guid.NewGuid(); - var result = await ContentPublishingService.PublishBranchAsync(key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsFalse(result); AssertBranchResultFailed(result.Result, (key, ContentPublishingOperationStatus.ContentNotFound)); } @@ -448,7 +448,7 @@ public partial class ContentPublishingServiceTests ContentService.Save(child, -1); Assert.AreEqual(content.Id, ContentService.GetById(child.Key)!.ParentId); - var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsFalse(result.Success); AssertBranchResultSuccess(result.Result, Textpage.Key, Subpage.Key, Subpage2.Key, Subpage3.Key); @@ -529,7 +529,7 @@ public partial class ContentPublishingServiceTests child.SetValue("title", "DA child title", culture: langDa.IsoCode); ContentService.Save(child); - var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode, langDa.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode, langDa.IsoCode }, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsFalse(result.Success); AssertBranchResultFailed(result.Result, (root.Key, ContentPublishingOperationStatus.ContentInvalid)); @@ -622,7 +622,7 @@ public partial class ContentPublishingServiceTests [Test] public async Task Cannot_Republish_Branch_After_Adding_Mandatory_Property() { - var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsTrue(result.Success); VerifyIsPublished(Textpage.Key); VerifyIsPublished(Subpage.Key); @@ -652,7 +652,7 @@ public partial class ContentPublishingServiceTests textPage.SetValue("mandatoryProperty", "This is a valid value"); ContentService.Save(textPage); - result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey); + result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, PublishBranchFilter.IncludeUnpublished, Constants.Security.SuperUserKey, false); Assert.IsFalse(result.Success); Assert.AreEqual(ContentPublishingOperationStatus.FailedBranch, result.Status); AssertBranchResultSuccess(result.Result, Textpage.Key); From 5f3ce280321be1d369ff7e597e33fea76d408c2c Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 4 Apr 2025 09:14:48 +0200 Subject: [PATCH 14/53] Clear roots before rebuilding navigation dictionary (#18766) * Clear roots before rebuilding navigation dictionary. * Added tests to verify fix. * Correct test implementation. * Convert integration tests with method overloads into test cases. * Integration test compatibility supressions. --- .../ContentNavigationServiceBase.cs | 8 ++++-- .../CompatibilitySuppressions.xml | 28 ++++++++++++++----- .../DocumentNavigationServiceTests.Rebuild.cs | 26 +++++++++++++---- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs b/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs index 531b3952a7..d7562a76b9 100644 --- a/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs +++ b/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs @@ -293,12 +293,12 @@ internal abstract class ContentNavigationServiceBaseThe read lock value, should be -333 or -334 for content and media trees. /// The key of the object type to rebuild. /// Indicates whether the items are in the recycle bin. - protected async Task HandleRebuildAsync(int readLock, Guid objectTypeKey, bool trashed) + protected Task HandleRebuildAsync(int readLock, Guid objectTypeKey, bool trashed) { // This is only relevant for items in the content and media trees if (readLock != Constants.Locks.ContentTree && readLock != Constants.Locks.MediaTree) { - return; + return Task.CompletedTask; } using ICoreScope scope = _coreScopeProvider.CreateCoreScope(autoComplete: true); @@ -307,14 +307,18 @@ internal abstract class ContentNavigationServiceBase navigationModels = _navigationRepository.GetTrashedContentNodesByObjectType(objectTypeKey); BuildNavigationDictionary(_recycleBinNavigationStructure, _recycleBinRoots, navigationModels); } else { + _roots.Clear(); IEnumerable navigationModels = _navigationRepository.GetContentNodesByObjectType(objectTypeKey); BuildNavigationDictionary(_navigationStructure, _roots, navigationModels); } + + return Task.CompletedTask; } private bool TryGetParentKeyFromStructure(ConcurrentDictionary structure, Guid childKey, out Guid? parentKey) diff --git a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml index 880504a1d4..3b12394aaa 100644 --- a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml +++ b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml @@ -78,6 +78,20 @@ lib/net9.0/Umbraco.Tests.Integration.dll true + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.DocumentNavigationServiceTests.Bin_Structure_Can_Rebuild + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.DocumentNavigationServiceTests.Structure_Can_Rebuild + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + CP0002 M:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.UserServiceCrudTests.Cannot_Request_Disabled_If_Hidden(Umbraco.Cms.Core.Models.Membership.UserState) @@ -92,6 +106,13 @@ lib/net9.0/Umbraco.Tests.Integration.dll true + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.EntityServiceTests.CreateTestData + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + CP0002 M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.MemberEditingServiceTests.Cannot_Change_IsApproved_Without_Access @@ -106,13 +127,6 @@ lib/net9.0/Umbraco.Tests.Integration.dll true - - CP0002 - M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.EntityServiceTests.CreateTestData - lib/net9.0/Umbraco.Tests.Integration.dll - lib/net9.0/Umbraco.Tests.Integration.dll - true - CP0002 M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.TemplateServiceTests.Deleting_Master_Template_Also_Deletes_Children diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs index 52f9bb77bd..0389152074 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentNavigationServiceTests.Rebuild.cs @@ -9,8 +9,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; public partial class DocumentNavigationServiceTests { - [Test] - public async Task Structure_Can_Rebuild() + [TestCase(1, TestName = "Structure_Can_Rebuild")] + [TestCase(2, TestName = "Structure_Can_Rebuild_MultipleTimes")] + public async Task Structure_Can_Rebuild(int numberOfRebuilds) { // Arrange Guid nodeKey = Root.Key; @@ -21,6 +22,7 @@ public partial class DocumentNavigationServiceTests DocumentNavigationQueryService.TryGetDescendantsKeys(nodeKey, out IEnumerable originalDescendantsKeys); DocumentNavigationQueryService.TryGetAncestorsKeys(nodeKey, out IEnumerable originalAncestorsKeys); DocumentNavigationQueryService.TryGetSiblingsKeys(nodeKey, out IEnumerable originalSiblingsKeys); + DocumentNavigationQueryService.TryGetRootKeys(out IEnumerable originalRouteKeys); // In-memory navigation structure is empty here var newDocumentNavigationService = new DocumentNavigationService( @@ -30,7 +32,10 @@ public partial class DocumentNavigationServiceTests var initialNodeExists = newDocumentNavigationService.TryGetParentKey(nodeKey, out _); // Act - await newDocumentNavigationService.RebuildAsync(); + for (int i = 0; i < numberOfRebuilds; i++) + { + await newDocumentNavigationService.RebuildAsync(); + } // Capture rebuilt state var nodeExists = newDocumentNavigationService.TryGetParentKey(nodeKey, out Guid? parentKeyFromRebuild); @@ -38,6 +43,7 @@ public partial class DocumentNavigationServiceTests newDocumentNavigationService.TryGetDescendantsKeys(nodeKey, out IEnumerable descendantsKeysFromRebuild); newDocumentNavigationService.TryGetAncestorsKeys(nodeKey, out IEnumerable ancestorsKeysFromRebuild); newDocumentNavigationService.TryGetSiblingsKeys(nodeKey, out IEnumerable siblingsKeysFromRebuild); + newDocumentNavigationService.TryGetRootKeys(out IEnumerable routeKeysFromRebuild); // Assert Assert.Multiple(() => @@ -53,11 +59,13 @@ public partial class DocumentNavigationServiceTests CollectionAssert.AreEquivalent(originalDescendantsKeys, descendantsKeysFromRebuild); CollectionAssert.AreEquivalent(originalAncestorsKeys, ancestorsKeysFromRebuild); CollectionAssert.AreEquivalent(originalSiblingsKeys, siblingsKeysFromRebuild); + CollectionAssert.AreEquivalent(originalRouteKeys, routeKeysFromRebuild); }); } - [Test] - public async Task Bin_Structure_Can_Rebuild() + [TestCase(1, TestName = "Bin_Structure_Can_Rebuild")] + [TestCase(2, TestName = "Bin_Structure_Can_Rebuild_MultipleTimes")] + public async Task Bin_Structure_Can_Rebuild(int numberOfRebuilds) { // Arrange Guid nodeKey = Root.Key; @@ -69,6 +77,7 @@ public partial class DocumentNavigationServiceTests DocumentNavigationQueryService.TryGetDescendantsKeysInBin(nodeKey, out IEnumerable originalDescendantsKeys); DocumentNavigationQueryService.TryGetAncestorsKeysInBin(nodeKey, out IEnumerable originalAncestorsKeys); DocumentNavigationQueryService.TryGetSiblingsKeysInBin(nodeKey, out IEnumerable originalSiblingsKeys); + DocumentNavigationQueryService.TryGetRootKeys(out IEnumerable originalRouteKeys); // In-memory navigation structure is empty here var newDocumentNavigationService = new DocumentNavigationService( @@ -78,7 +87,10 @@ public partial class DocumentNavigationServiceTests var initialNodeExists = newDocumentNavigationService.TryGetParentKeyInBin(nodeKey, out _); // Act - await newDocumentNavigationService.RebuildBinAsync(); + for (int i = 0; i < numberOfRebuilds; i++) + { + await newDocumentNavigationService.RebuildBinAsync(); + } // Capture rebuilt state var nodeExists = newDocumentNavigationService.TryGetParentKeyInBin(nodeKey, out Guid? parentKeyFromRebuild); @@ -86,6 +98,7 @@ public partial class DocumentNavigationServiceTests newDocumentNavigationService.TryGetDescendantsKeysInBin(nodeKey, out IEnumerable descendantsKeysFromRebuild); newDocumentNavigationService.TryGetAncestorsKeysInBin(nodeKey, out IEnumerable ancestorsKeysFromRebuild); newDocumentNavigationService.TryGetSiblingsKeysInBin(nodeKey, out IEnumerable siblingsKeysFromRebuild); + newDocumentNavigationService.TryGetRootKeys(out IEnumerable routeKeysFromRebuild); // Assert Assert.Multiple(() => @@ -101,6 +114,7 @@ public partial class DocumentNavigationServiceTests CollectionAssert.AreEquivalent(originalDescendantsKeys, descendantsKeysFromRebuild); CollectionAssert.AreEquivalent(originalAncestorsKeys, ancestorsKeysFromRebuild); CollectionAssert.AreEquivalent(originalSiblingsKeys, siblingsKeysFromRebuild); + CollectionAssert.AreEquivalent(originalRouteKeys, routeKeysFromRebuild); }); } } From ebd5fb4d0c4e7bb16ad30f8c0b6917172db90908 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 4 Apr 2025 10:14:24 +0200 Subject: [PATCH 15/53] Fixes save of empty, invariant block list on variant content. (#18932) --- .../PropertyEditors/BlockValuePropertyValueEditorBase.cs | 4 ++++ .../BlockListEditorPropertyValueEditorTests.cs | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs index 91d3ddf2ad..d2a5d0695b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs @@ -296,6 +296,10 @@ public abstract class BlockValuePropertyValueEditorBase : DataV TValue? mergedBlockValue = MergeVariantInvariantPropertyValueTyped(source, target, canUpdateInvariantData, allowedCultures); + if (mergedBlockValue is null) + { + return null; + } return _jsonSerializer.Serialize(mergedBlockValue); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs index 9edc858532..879400f79a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs @@ -75,6 +75,14 @@ public class BlockListEditorPropertyValueEditorTests } } + [Test] + public void MergeVariantInvariantPropertyValue_Can_Merge_Null_Values() + { + var editor = CreateValueEditor(); + var result = editor.MergeVariantInvariantPropertyValue(null, null, true, ["en-US"]); + Assert.IsNull(result); + } + private static JsonObject CreateBlocksJson(int numberOfBlocks) { var layoutItems = new JsonArray(); From 788e5cd678559cf24ce5698e73b96b5d707510e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 4 Apr 2025 11:18:30 +0200 Subject: [PATCH 16/53] remove unnecessary code (#18927) --- .../entity-detail/entity-detail-workspace-base.ts | 7 ------- 1 file changed, 7 deletions(-) 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 08db0ed87f..08b396b515 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 @@ -359,13 +359,6 @@ export abstract class UmbEntityDetailWorkspaceContextBase< return true; } - /* TODO: temp removal of discard changes in workspace modals. - The modal closes before the discard changes dialog is resolved.*/ - // TODO: I think this can go away now??? - if (newUrl.includes('/modal/umb-modal-workspace/')) { - return true; - } - if (this._checkWillNavigateAway(newUrl) && this._getHasUnpersistedChanges()) { /* Since ours modals are async while events are synchronous, we need to prevent the default behavior of the event, even if the modal hasn’t been resolved yet. Once the modal is resolved (the user accepted to discard the changes and navigate away from the route), we will push a new history state. From 806f10e52ac01e0bb9076af32d90bd2cb9a43f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 4 Apr 2025 11:20:29 +0200 Subject: [PATCH 17/53] V15/bugfix/fix route issue from 18859 (#18931) * unique check * unique for workspace empty path * more unique routes --- src/Umbraco.Web.UI.Client/src/external/router-slot/model.ts | 3 +++ .../src/external/router-slot/router-slot.ts | 6 ++---- .../src/external/router-slot/util/router.ts | 5 +++-- .../src/packages/core/collection/collection-view.manager.ts | 1 + .../src/packages/core/modal/component/modal.element.ts | 1 + .../core/router/components/router-slot/route.context.ts | 2 ++ .../components/workspace-editor/workspace-editor.element.ts | 4 ++-- 7 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/model.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/model.ts index 6872b18149..5c4d4240ba 100644 --- a/src/Umbraco.Web.UI.Client/src/external/router-slot/model.ts +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/model.ts @@ -62,6 +62,9 @@ export interface IRouteBase { // - If "full" router-slot will try to match the entire path. // - If "fuzzy" router-slot will try to match an arbitrary part of the path. pathMatch?: PathMatch; + + // A unique identifier for the route, used to identify the route so we can avoid re-rendering it. + unique?: string | Symbol; } /** diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/router-slot.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/router-slot.ts index 007c8d6c32..d21c76461e 100644 --- a/src/Umbraco.Web.UI.Client/src/external/router-slot/router-slot.ts +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/router-slot.ts @@ -208,10 +208,8 @@ export class RouterSlot extends HTMLElement implements IRouter // If navigate is not determined, then we will check if we have a route match, and if the new match is different from current. [NL] const newMatch = this.getRouteMatch(); if (newMatch) { - if (this._routeMatch?.route.path !== newMatch.route.path) { - // Check if this match matches the current match (aka. If the path has changed), if so we should navigate. [NL] - navigate = shouldNavigate(this.match, newMatch); - } + // Check if this match matches the current match (aka. If the path has changed), if so we should navigate. [NL] + navigate = shouldNavigate(this.match, newMatch); } } } diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/util/router.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/router.ts index 7c731abe59..2eb410f903 100644 --- a/src/Umbraco.Web.UI.Client/src/external/router-slot/util/router.ts +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/router.ts @@ -301,9 +301,10 @@ export function shouldNavigate(currentMatch: IRouteMatch | null, newMatch: const { route: currentRoute, fragments: currentFragments } = currentMatch; const { route: newRoute, fragments: newFragments } = newMatch; - const isSameRoute = currentRoute == newRoute; + const isSameRoute = currentRoute.path == newRoute.path; const isSameFragments = currentFragments.consumed == newFragments.consumed; + const isSameBasedOnUnique = currentRoute.unique === newRoute.unique; // Only navigate if the URL consumption is new or if the two routes are no longer the same. - return !isSameFragments || !isSameRoute; + return !isSameFragments || !isSameRoute || !isSameBasedOnUnique; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.ts index d7fc98c2af..2c7832a555 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.ts @@ -94,6 +94,7 @@ export class UmbCollectionViewManager extends UmbControllerBase { if (routes.length > 0) { routes.push({ + unique: fallbackView.alias, path: '', component: () => createExtensionElement(fallbackView), setup: () => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts index 9313a8de31..1096ec181e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts @@ -95,6 +95,7 @@ export class UmbModalElement extends UmbLitElement { this.#modalRouterElement = document.createElement('umb-router-slot'); this.#modalRouterElement.routes = [ { + unique: '_umbEmptyRoute_', path: '', component: document.createElement('slot'), }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.context.ts index 2bd561dbfb..ea40a677f8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.context.ts @@ -60,6 +60,7 @@ export class UmbRouteContext extends UmbContextBase { #generateRoute(modalRegistration: UmbModalRouteRegistration): UmbRoutePlusModalKey { return { __modalKey: modalRegistration.key, + unique: 'umbModalKey_' + modalRegistration.key, path: '/' + modalRegistration.generateModalPath(), component: EmptyDiv, setup: async (component, info) => { @@ -112,6 +113,7 @@ export class UmbRouteContext extends UmbContextBase { // Add an empty route, so there is a route for the router to react on when no modals are open. this.#modalRoutes.push({ __modalKey: '_empty_', + unique: 'umbEmptyModal', path: '', component: EmptyDiv, }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts index 845b2248af..ae686a5243 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts @@ -70,11 +70,11 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { (component as any).manifest = manifest; } }, - } as UmbRoute; + }; }); // Duplicate first workspace and use it for the empty path scenario. [NL] - newRoutes.push({ ...newRoutes[0], path: '' }); + newRoutes.push({ ...newRoutes[0], unique: newRoutes[0].path, path: '' }); newRoutes.push({ path: `**`, From 90a34e17ca1b297cd399c5deaf898adca705726c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Apr 2025 07:03:08 +0000 Subject: [PATCH 18/53] Bump vite from 6.2.3 to 6.2.4 in /src/Umbraco.Web.UI.Client Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.2.3 to 6.2.4. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v6.2.4/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v6.2.4/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 6.2.4 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- src/Umbraco.Web.UI.Client/package-lock.json | 8 ++++---- src/Umbraco.Web.UI.Client/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index a4c0a878fe..8bc9988b65 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -94,7 +94,7 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.24.1", "typescript-json-schema": "^0.65.1", - "vite": "^6.2.3", + "vite": "^6.2.4", "vite-plugin-static-copy": "^2.2.0", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" @@ -16885,9 +16885,9 @@ } }, "node_modules/vite": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz", - "integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz", + "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index bb52490dc0..9c8ec90343 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -284,7 +284,7 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.24.1", "typescript-json-schema": "^0.65.1", - "vite": "^6.2.3", + "vite": "^6.2.4", "vite-plugin-static-copy": "^2.2.0", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" From c1390150191c44c11bb5dcaaea2ef2e43d79ddba Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 4 Apr 2025 14:10:45 +0200 Subject: [PATCH 19/53] removes autogenerated workflows --- ...e-static-web-apps-orange-sea-0c7411a03.yml | 46 ------------------- ...c-web-apps-victorious-ground-017b08103.yml | 46 ------------------- ...-static-web-apps-yellow-bush-073fc4d10.yml | 46 ------------------- 3 files changed, 138 deletions(-) delete mode 100644 .github/workflows/azure-static-web-apps-orange-sea-0c7411a03.yml delete mode 100644 .github/workflows/azure-static-web-apps-victorious-ground-017b08103.yml delete mode 100644 .github/workflows/azure-static-web-apps-yellow-bush-073fc4d10.yml diff --git a/.github/workflows/azure-static-web-apps-orange-sea-0c7411a03.yml b/.github/workflows/azure-static-web-apps-orange-sea-0c7411a03.yml deleted file mode 100644 index 8f1351f93f..0000000000 --- a/.github/workflows/azure-static-web-apps-orange-sea-0c7411a03.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Azure Static Web Apps CI/CD - -on: - push: - branches: - - contrib - pull_request: - types: [opened, synchronize, reopened, closed] - branches: - - contrib - -jobs: - build_and_deploy_job: - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') - runs-on: ubuntu-latest - name: Build and Deploy Job - steps: - - uses: actions/checkout@v3 - with: - submodules: true - lfs: false - - name: Build And Deploy - id: builddeploy - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_ORANGE_SEA_0C7411A03 }} - repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) - action: "upload" - ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### - # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig - app_location: "/" # App source code path - api_location: "" # Api source code path - optional - output_location: "" # Built app content directory - optional - ###### End of Repository/Build Configurations ###### - - close_pull_request_job: - if: github.event_name == 'pull_request' && github.event.action == 'closed' - runs-on: ubuntu-latest - name: Close Pull Request Job - steps: - - name: Close Pull Request - id: closepullrequest - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_ORANGE_SEA_0C7411A03 }} - action: "close" diff --git a/.github/workflows/azure-static-web-apps-victorious-ground-017b08103.yml b/.github/workflows/azure-static-web-apps-victorious-ground-017b08103.yml deleted file mode 100644 index 1ce226e036..0000000000 --- a/.github/workflows/azure-static-web-apps-victorious-ground-017b08103.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Azure Static Web Apps CI/CD - -on: - push: - branches: - - contrib - pull_request: - types: [opened, synchronize, reopened, closed] - branches: - - contrib - -jobs: - build_and_deploy_job: - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') - runs-on: ubuntu-latest - name: Build and Deploy Job - steps: - - uses: actions/checkout@v3 - with: - submodules: true - lfs: false - - name: Build And Deploy - id: builddeploy - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_VICTORIOUS_GROUND_017B08103 }} - repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) - action: "upload" - ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### - # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig - app_location: "/" # App source code path - api_location: "" # Api source code path - optional - output_location: "" # Built app content directory - optional - ###### End of Repository/Build Configurations ###### - - close_pull_request_job: - if: github.event_name == 'pull_request' && github.event.action == 'closed' - runs-on: ubuntu-latest - name: Close Pull Request Job - steps: - - name: Close Pull Request - id: closepullrequest - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_VICTORIOUS_GROUND_017B08103 }} - action: "close" diff --git a/.github/workflows/azure-static-web-apps-yellow-bush-073fc4d10.yml b/.github/workflows/azure-static-web-apps-yellow-bush-073fc4d10.yml deleted file mode 100644 index 3f8fb138f1..0000000000 --- a/.github/workflows/azure-static-web-apps-yellow-bush-073fc4d10.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Azure Static Web Apps CI/CD - -on: - push: - branches: - - contrib - pull_request: - types: [opened, synchronize, reopened, closed] - branches: - - contrib - -jobs: - build_and_deploy_job: - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') - runs-on: ubuntu-latest - name: Build and Deploy Job - steps: - - uses: actions/checkout@v3 - with: - submodules: true - lfs: false - - name: Build And Deploy - id: builddeploy - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_YELLOW_BUSH_073FC4D10 }} - repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) - action: "upload" - ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### - # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig - app_location: "/" # App source code path - api_location: "" # Api source code path - optional - output_location: "" # Built app content directory - optional - ###### End of Repository/Build Configurations ###### - - close_pull_request_job: - if: github.event_name == 'pull_request' && github.event.action == 'closed' - runs-on: ubuntu-latest - name: Close Pull Request Job - steps: - - name: Close Pull Request - id: closepullrequest - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_YELLOW_BUSH_073FC4D10 }} - action: "close" From bf74636f2649438309dd2ecb9359c1558d81679d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 4 Apr 2025 14:42:35 +0200 Subject: [PATCH 20/53] make getHasUnpersistedChanges public (#18929) --- .../block/workspace/block-element-manager.ts | 8 ++++++++ .../block/workspace/block-workspace.context.ts | 8 ++++++++ .../entity-detail/entity-detail-workspace-base.ts | 15 ++++++++++++--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts index 5c18b09b68..e05e9ced72 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts @@ -119,6 +119,14 @@ export class UmbBlockElementManager Date: Fri, 4 Apr 2025 14:52:39 +0200 Subject: [PATCH 21/53] Added management API endpoint, service and repository for retrieval of references from the recycle bin (#18882) * Added management API endpoint, service and repository for retrieval of references from the recycle bin. * Update src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/ReferencedByDocumentRecycleBinController.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Removed unused code. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ...eferencedByDocumentRecycleBinController.cs | 50 ++++++++ .../ReferencedByMediaRecycleBinController.cs | 50 ++++++++ src/Umbraco.Cms.Api.Management/OpenApi.json | 110 ++++++++++++++++++ .../ITrackedReferencesRepository.cs | 23 ++++ .../Services/ITrackedReferencesService.cs | 14 +++ .../Services/TrackedReferencesService.cs | 15 +++ .../Implement/TrackedReferencesRepository.cs | 69 +++++++++-- .../CompatibilitySuppressions.xml | 7 ++ .../Services/TrackedReferencesServiceTests.cs | 20 +++- 9 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/ReferencedByDocumentRecycleBinController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/ReferencedByMediaRecycleBinController.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/ReferencedByDocumentRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/ReferencedByDocumentRecycleBinController.cs new file mode 100644 index 0000000000..2b94fb443f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/ReferencedByDocumentRecycleBinController.cs @@ -0,0 +1,50 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Document.RecycleBin; + +[ApiVersion("1.0")] +public class ReferencedByDocumentRecycleBinController : DocumentRecycleBinControllerBase +{ + private readonly ITrackedReferencesService _trackedReferencesService; + private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; + + public ReferencedByDocumentRecycleBinController( + IEntityService entityService, + IDocumentPresentationFactory documentPresentationFactory, + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) + : base(entityService, documentPresentationFactory) + { + _trackedReferencesService = trackedReferencesService; + _relationTypePresentationFactory = relationTypePresentationFactory; + } + + /// + /// Gets a paged list of tracked references for all items in the document recycle bin, so you can see where an item is being used. + /// + [HttpGet("referenced-by")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> ReferencedBy( + CancellationToken cancellationToken, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes.Document, skip, take, true); + + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + }; + + return pagedViewModel; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/ReferencedByMediaRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/ReferencedByMediaRecycleBinController.cs new file mode 100644 index 0000000000..a3c72184d7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/ReferencedByMediaRecycleBinController.cs @@ -0,0 +1,50 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Media.RecycleBin; + +[ApiVersion("1.0")] +public class ReferencedByMediaRecycleBinController : MediaRecycleBinControllerBase +{ + private readonly ITrackedReferencesService _trackedReferencesService; + private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; + + public ReferencedByMediaRecycleBinController( + IEntityService entityService, + IMediaPresentationFactory mediaPresentationFactory, + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) + : base(entityService, mediaPresentationFactory) + { + _trackedReferencesService = trackedReferencesService; + _relationTypePresentationFactory = relationTypePresentationFactory; + } + + /// + /// Gets a paged list of tracked references for all items in the media recycle bin, so you can see where an item is being used. + /// + [HttpGet("referenced-by")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> ReferencedBy( + CancellationToken cancellationToken, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes.Media, skip, take, true); + + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + }; + + return pagedViewModel; + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 239aebc54a..e87920c9ba 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -10705,6 +10705,61 @@ ] } }, + "/umbraco/management/api/v1/recycle-bin/document/referenced-by": { + "get": { + "tags": [ + "Document" + ], + "operationId": "GetRecycleBinDocumentReferencedBy", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedIReferenceResponseModel" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/recycle-bin/document/root": { "get": { "tags": [ @@ -17856,6 +17911,61 @@ ] } }, + "/umbraco/management/api/v1/recycle-bin/media/referenced-by": { + "get": { + "tags": [ + "Media" + ], + "operationId": "GetRecycleBinMediaReferencedBy", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedIReferenceResponseModel" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/recycle-bin/media/root": { "get": { "tags": [ diff --git a/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs index 01bff9f356..270ed24250 100644 --- a/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs @@ -70,6 +70,29 @@ public interface ITrackedReferencesRepository bool filterMustBeIsDependency, out long totalRecords); + /// + /// Gets a paged result of items which are in relation with an item in the recycle bin. + /// + /// The Umbraco object type that has recycle bin support (currently Document or Media). + /// The amount of items to skip. + /// The amount of items to take. + /// + /// A boolean indicating whether to filter only the RelationTypes which are + /// dependencies (isDependency field is set to true). + /// + /// The total count of the items with reference to the current item. + /// An enumerable list of objects. + IEnumerable GetPagedRelationsForRecycleBin( + Guid objectTypeKey, + long skip, + long take, + bool filterMustBeIsDependency, + out long totalRecords) + { + totalRecords = 0; + return []; + } + [Obsolete("Use overload that takes key instead of id. This will be removed in Umbraco 15.")] IEnumerable GetPagedRelationsForItem( int id, diff --git a/src/Umbraco.Core/Services/ITrackedReferencesService.cs b/src/Umbraco.Core/Services/ITrackedReferencesService.cs index c60156d31b..74578cc23b 100644 --- a/src/Umbraco.Core/Services/ITrackedReferencesService.cs +++ b/src/Umbraco.Core/Services/ITrackedReferencesService.cs @@ -64,6 +64,20 @@ public interface ITrackedReferencesService /// A paged result of objects. Task> GetPagedRelationsForItemAsync(Guid key, long skip, long take, bool filterMustBeIsDependency); + /// + /// Gets a paged result of items which are in relation with an item in the recycle bin. + /// + /// The Umbraco object type that has recycle bin support (currently Document or Media). + /// The amount of items to skip + /// The amount of items to take. + /// + /// A boolean indicating whether to filter only the RelationTypes which are + /// dependencies (isDependency field is set to true). + /// + /// A paged result of objects. + Task> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) + => Task.FromResult(new PagedModel(0, [])); + [Obsolete("Use method that takes key (Guid) instead of id (int). This will be removed in Umbraco 15.")] PagedModel GetPagedDescendantsInReferences(int parentId, long skip, long take, bool filterMustBeIsDependency); diff --git a/src/Umbraco.Core/Services/TrackedReferencesService.cs b/src/Umbraco.Core/Services/TrackedReferencesService.cs index 5d5d76f7f0..0d6ed003b8 100644 --- a/src/Umbraco.Core/Services/TrackedReferencesService.cs +++ b/src/Umbraco.Core/Services/TrackedReferencesService.cs @@ -92,6 +92,21 @@ public class TrackedReferencesService : ITrackedReferencesService return await Task.FromResult(pagedModel); } + public async Task> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) + { + Guid objectTypeKey = objectType switch + { + UmbracoObjectTypes.Document => Constants.ObjectTypes.Document, + UmbracoObjectTypes.Media => Constants.ObjectTypes.Media, + _ => throw new ArgumentOutOfRangeException(nameof(objectType), "Only documents and media have recycle bin support."), + }; + + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + IEnumerable items = _trackedReferencesRepository.GetPagedRelationsForRecycleBin(objectTypeKey, skip, take, filterMustBeIsDependency, out var totalItems); + var pagedModel = new PagedModel(totalItems, items); + return await Task.FromResult(pagedModel); + } + [Obsolete("Use overload that takes key instead of id. This will be removed in Umbraco 15.")] public PagedModel GetPagedDescendantsInReferences(int parentId, long skip, long take, bool filterMustBeIsDependency) { diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs index 2b7a43589c..ced64f4996 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using NPoco; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; @@ -92,8 +93,16 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement } Sql innerUnionSqlChild = _scopeAccessor.AmbientScope.Database.SqlContext.Sql().Select( - "[cn].uniqueId as [key]", "[pn].uniqueId as otherKey, [cr].childId as id", "[cr].parentId as otherId", "[rt].[alias]", "[rt].[name]", - "[rt].[isDependency]", "[rt].[dual]") + "[cn].uniqueId as [key]", + "[cn].trashed as [trashed]", + "[cn].nodeObjectType as [nodeObjectType]", + "[pn].uniqueId as otherKey," + + "[cr].childId as id", + "[cr].parentId as otherId", + "[rt].[alias]", + "[rt].[name]", + "[rt].[isDependency]", + "[rt].[dual]") .From("cr") .InnerJoin("rt") .On((cr, rt) => rt.Dual == false && rt.Id == cr.RelationType, "cr", "rt") @@ -103,8 +112,16 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement .On((cr, pn) => cr.ParentId == pn.NodeId, "cr", "pn"); Sql innerUnionSqlDualParent = _scopeAccessor.AmbientScope.Database.SqlContext.Sql().Select( - "[pn].uniqueId as [key]", "[cn].uniqueId as otherKey, [dpr].parentId as id", "[dpr].childId as otherId", "[dprt].[alias]", "[dprt].[name]", - "[dprt].[isDependency]", "[dprt].[dual]") + "[pn].uniqueId as [key]", + "[pn].trashed as [trashed]", + "[pn].nodeObjectType as [nodeObjectType]", + "[cn].uniqueId as otherKey," + + "[dpr].parentId as id", + "[dpr].childId as otherId", + "[dprt].[alias]", + "[dprt].[name]", + "[dprt].[isDependency]", + "[dprt].[dual]") .From("dpr") .InnerJoin("dprt") .On( @@ -115,8 +132,16 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement .On((dpr, pn) => dpr.ParentId == pn.NodeId, "dpr", "pn"); Sql innerUnionSql3 = _scopeAccessor.AmbientScope.Database.SqlContext.Sql().Select( - "[cn].uniqueId as [key]", "[pn].uniqueId as otherKey, [dcr].childId as id", "[dcr].parentId as otherId", "[dcrt].[alias]", "[dcrt].[name]", - "[dcrt].[isDependency]", "[dcrt].[dual]") + "[cn].uniqueId as [key]", + "[cn].trashed as [trashed]", + "[cn].nodeObjectType as [nodeObjectType]", + "[pn].uniqueId as otherKey," + + "[dcr].childId as id", + "[dcr].parentId as otherId", + "[dcrt].[alias]", + "[dcrt].[name]", + "[dcrt].[isDependency]", + "[dcrt].[dual]") .From("dcr") .InnerJoin("dcrt") .On( @@ -277,6 +302,32 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement long take, bool filterMustBeIsDependency, out long totalRecords) + => GetPagedRelations( + x => x.Key == key, + skip, + take, + filterMustBeIsDependency, + out totalRecords); + + public IEnumerable GetPagedRelationsForRecycleBin( + Guid objectTypeKey, + long skip, + long take, + bool filterMustBeIsDependency, + out long totalRecords) + => GetPagedRelations( + x => x.NodeObjectType == objectTypeKey && x.Trashed == true, + skip, + take, + filterMustBeIsDependency, + out totalRecords); + + private IEnumerable GetPagedRelations( + Expression> itemsFilter, + long skip, + long take, + bool filterMustBeIsDependency, + out long totalRecords) { Sql innerUnionSql = GetInnerUnionSql(); Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql().SelectDistinct( @@ -315,7 +366,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement (left, right) => left.NodeId == right.NodeId, aliasLeft: "n", aliasRight: "d") - .Where(x => x.Key == key, "x"); + .Where(itemsFilter, "x"); if (filterMustBeIsDependency) @@ -763,6 +814,10 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement [Column("key")] public Guid Key { get; set; } + [Column("trashed")] public bool Trashed { get; set; } + + [Column("nodeObjectType")] public Guid NodeObjectType { get; set; } + [Column("otherKey")] public Guid OtherKey { get; set; } [Column("alias")] public string? Alias { get; set; } diff --git a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml index 3b12394aaa..c3bf60b3cb 100644 --- a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml +++ b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml @@ -134,4 +134,11 @@ lib/net9.0/Umbraco.Tests.Integration.dll true + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.TrackedReferencesServiceTests.Does_not_return_references_if_item_is_not_referenced + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + \ No newline at end of file diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs index 27517e23dd..018da86cd0 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs @@ -80,7 +80,7 @@ public class TrackedReferencesServiceTests : UmbracoIntegrationTest } [Test] - public async Task Does_not_return_references_if_item_is_not_referenced() + public async Task Does_Not_Return_References_If_Item_Is_Not_Referenced() { var sut = GetRequiredService(); @@ -88,4 +88,22 @@ public class TrackedReferencesServiceTests : UmbracoIntegrationTest Assert.AreEqual(0, actual.Total); } + + [Test] + public async Task Get_Pages_That_Reference_Recycle_Bin_Contents() + { + ContentService.MoveToRecycleBin(Root1); + + var sut = GetRequiredService(); + + var actual = await sut.GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes.Document, 0, 10, true); + + Assert.Multiple(() => + { + Assert.AreEqual(1, actual.Total); + var item = actual.Items.FirstOrDefault(); + Assert.AreEqual(Root2.ContentType.Alias, item?.ContentTypeAlias); + Assert.AreEqual(Root2.Key, item?.NodeKey); + }); + } } From 1f4c19d4845349850f98b76d16ecb375d51b8b7d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 4 Apr 2025 15:10:06 +0200 Subject: [PATCH 22/53] Updated management API endpoint and model for data type references to align with that used for documents, media etc. (#18905) * Updated management API endpoint and model for data type references to align with that used for documents, media etc. * Refactoring. * Update src/Umbraco.Core/Constants-ReferenceTypes.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fixed typos. * Added id to tracked reference content type response. * Updated OpenApi.json. * Added missing updates. * Renamed model and constants from code review feedback. * Fix typo * Fix multiple enumeration --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: mole --- .../ReferencedByDataTypeController.cs | 46 ++++ .../DataType/ReferencesDataTypeController.cs | 3 +- .../RelationTypePresentationFactory.cs | 9 +- ...TrackedReferenceViewModelsMapDefinition.cs | 51 +++++ src/Umbraco.Cms.Api.Management/OpenApi.json | 212 ++++++++++++++++++ ...tTypePropertyTypeReferenceResponseModel.cs | 6 + ...tTypePropertyTypeReferenceResponseModel.cs | 6 + ...aTypePropertyTypeReferenceResponseModel.cs | 6 + ...rTypePropertyTypeReferenceResponseModel.cs | 6 + .../TrackedReferenceContentType.cs | 4 +- src/Umbraco.Core/Constants-ReferenceTypes.cs | 25 +++ src/Umbraco.Core/Models/RelationItem.cs | 3 + src/Umbraco.Core/Models/RelationItemModel.cs | 6 +- .../Repositories/IDataTypeRepository.cs | 1 - src/Umbraco.Core/Services/DataTypeService.cs | 139 ++++++++++++ src/Umbraco.Core/Services/IDataTypeService.cs | 18 ++ .../Mapping/RelationModelMapDefinition.cs | 3 +- .../Implement/RelationRepository.cs | 3 + .../Implement/TrackedReferencesRepository.cs | 10 + .../Builders/MediaTypeBuilder.cs | 1 + .../Services/DataTypeServiceTests.cs | 41 ++++ 21 files changed, 591 insertions(+), 8 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DataType/References/ReferencedByDataTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/ContentTypePropertyTypeReferenceResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/DocumentTypePropertyTypeReferenceResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MediaTypePropertyTypeReferenceResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MemberTypePropertyTypeReferenceResponseModel.cs create mode 100644 src/Umbraco.Core/Constants-ReferenceTypes.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/References/ReferencedByDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/References/ReferencedByDataTypeController.cs new file mode 100644 index 0000000000..b36815d408 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/References/ReferencedByDataTypeController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.DataType.References; + +[ApiVersion("1.0")] +public class ReferencedByDataTypeController : DataTypeControllerBase +{ + private readonly IDataTypeService _dataTypeService; + private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; + + public ReferencedByDataTypeController(IDataTypeService dataTypeService, IRelationTypePresentationFactory relationTypePresentationFactory) + { + _dataTypeService = dataTypeService; + _relationTypePresentationFactory = relationTypePresentationFactory; + } + + /// + /// Gets a paged list of references for the current data type, so you can see where it is being used. + /// + [HttpGet("{id:guid}/referenced-by")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> ReferencedBy( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _dataTypeService.GetPagedRelationsAsync(id, skip, take); + + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + }; + + return pagedViewModel; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/ReferencesDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/ReferencesDataTypeController.cs index c25586e93c..0eee28e49b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/ReferencesDataTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/ReferencesDataTypeController.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.DataType; [ApiVersion("1.0")] +[Obsolete("Please use ReferencedByDataTypeController and the referenced-by endpoint. Scheduled for removal in Umbraco 17.")] public class ReferencesDataTypeController : DataTypeControllerBase { private readonly IDataTypeService _dataTypeService; diff --git a/src/Umbraco.Cms.Api.Management/Factories/RelationTypePresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/RelationTypePresentationFactory.cs index a6fb1cbae8..40bcf9f04c 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/RelationTypePresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/RelationTypePresentationFactory.cs @@ -56,9 +56,12 @@ public class RelationTypePresentationFactory : IRelationTypePresentationFactory IReferenceResponseModel[] result = relationItemModelsCollection.Select(relationItemModel => relationItemModel.NodeType switch { - Constants.UdiEntityType.Document => MapDocumentReference(relationItemModel, slimEntities), - Constants.UdiEntityType.Media => _umbracoMapper.Map(relationItemModel), - Constants.UdiEntityType.Member => _umbracoMapper.Map(relationItemModel), + Constants.ReferenceType.Document => MapDocumentReference(relationItemModel, slimEntities), + Constants.ReferenceType.Media => _umbracoMapper.Map(relationItemModel), + Constants.ReferenceType.Member => _umbracoMapper.Map(relationItemModel), + Constants.ReferenceType.DocumentTypePropertyType => _umbracoMapper.Map(relationItemModel), + Constants.ReferenceType.MediaTypePropertyType => _umbracoMapper.Map(relationItemModel), + Constants.ReferenceType.MemberTypePropertyType => _umbracoMapper.Map(relationItemModel), _ => _umbracoMapper.Map(relationItemModel), }).WhereNotNull().ToArray(); diff --git a/src/Umbraco.Cms.Api.Management/Mapping/TrackedReferences/TrackedReferenceViewModelsMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/TrackedReferences/TrackedReferenceViewModelsMapDefinition.cs index e9f2700f5a..c4efca3088 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/TrackedReferences/TrackedReferenceViewModelsMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/TrackedReferences/TrackedReferenceViewModelsMapDefinition.cs @@ -12,6 +12,9 @@ public class TrackedReferenceViewModelsMapDefinition : IMapDefinition mapper.Define((source, context) => new DocumentReferenceResponseModel(), Map); mapper.Define((source, context) => new MediaReferenceResponseModel(), Map); mapper.Define((source, context) => new MemberReferenceResponseModel(), Map); + mapper.Define((source, context) => new DocumentTypePropertyTypeReferenceResponseModel(), Map); + mapper.Define((source, context) => new MediaTypePropertyTypeReferenceResponseModel(), Map); + mapper.Define((source, context) => new MemberTypePropertyTypeReferenceResponseModel(), Map); mapper.Define((source, context) => new DefaultReferenceResponseModel(), Map); mapper.Define((source, context) => new ReferenceByIdModel(), Map); mapper.Define((source, context) => new ReferenceByIdModel(), Map); @@ -25,6 +28,7 @@ public class TrackedReferenceViewModelsMapDefinition : IMapDefinition target.Published = source.NodePublished; target.DocumentType = new TrackedReferenceDocumentType { + Id = source.ContentTypeKey, Alias = source.ContentTypeAlias, Icon = source.ContentTypeIcon, Name = source.ContentTypeName, @@ -38,6 +42,7 @@ public class TrackedReferenceViewModelsMapDefinition : IMapDefinition target.Name = source.NodeName; target.MediaType = new TrackedReferenceMediaType { + Id = source.ContentTypeKey, Alias = source.ContentTypeAlias, Icon = source.ContentTypeIcon, Name = source.ContentTypeName, @@ -51,6 +56,52 @@ public class TrackedReferenceViewModelsMapDefinition : IMapDefinition target.Name = source.NodeName; target.MemberType = new TrackedReferenceMemberType { + Id = source.ContentTypeKey, + Alias = source.ContentTypeAlias, + Icon = source.ContentTypeIcon, + Name = source.ContentTypeName, + }; + } + + // Umbraco.Code.MapAll + private void Map(RelationItemModel source, DocumentTypePropertyTypeReferenceResponseModel target, MapperContext context) + { + target.Id = source.NodeKey; + target.Name = source.NodeName; + target.Alias = source.NodeAlias; + target.DocumentType = new TrackedReferenceDocumentType + { + Id = source.ContentTypeKey, + Alias = source.ContentTypeAlias, + Icon = source.ContentTypeIcon, + Name = source.ContentTypeName, + }; + } + + // Umbraco.Code.MapAll + private void Map(RelationItemModel source, MediaTypePropertyTypeReferenceResponseModel target, MapperContext context) + { + target.Id = source.NodeKey; + target.Name = source.NodeName; + target.Alias = source.NodeAlias; + target.MediaType = new TrackedReferenceMediaType + { + Id = source.ContentTypeKey, + Alias = source.ContentTypeAlias, + Icon = source.ContentTypeIcon, + Name = source.ContentTypeName, + }; + } + + // Umbraco.Code.MapAll + private void Map(RelationItemModel source, MemberTypePropertyTypeReferenceResponseModel target, MapperContext context) + { + target.Id = source.NodeKey; + target.Name = source.NodeName; + target.Alias = source.NodeAlias; + target.MemberType = new TrackedReferenceMemberType + { + Id = source.ContentTypeKey, Alias = source.ContentTypeAlias, Icon = source.ContentTypeIcon, Name = source.ContentTypeName, diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index e87920c9ba..304e344e62 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -816,6 +816,70 @@ ] } }, + "/umbraco/management/api/v1/data-type/{id}/referenced-by": { + "get": { + "tags": [ + "Data Type" + ], + "operationId": "GetDataTypeByIdReferencedBy", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedIReferenceResponseModel" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/data-type/{id}/references": { "get": { "tags": [ @@ -872,6 +936,7 @@ "description": "The authenticated user does not have access to this resource" } }, + "deprecated": true, "security": [ { "Backoffice User": [ ] @@ -37931,6 +37996,45 @@ }, "additionalProperties": false }, + "DocumentTypePropertyReferenceResponseModel": { + "required": [ + "$type", + "documentType", + "id" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "nullable": true + }, + "alias": { + "type": "string", + "nullable": true + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrackedReferenceDocumentTypeModel" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTypePropertyReferenceResponseModel": "#/components/schemas/DocumentTypePropertyReferenceResponseModel" + } + } + }, "DocumentTypePropertyTypeContainerResponseModel": { "required": [ "id", @@ -39995,6 +40099,45 @@ }, "additionalProperties": false }, + "MediaTypePropertyReferenceResponseModel": { + "required": [ + "$type", + "id", + "mediaType" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "nullable": true + }, + "alias": { + "type": "string", + "nullable": true + }, + "mediaType": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrackedReferenceMediaTypeModel" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "MediaTypePropertyReferenceResponseModel": "#/components/schemas/MediaTypePropertyReferenceResponseModel" + } + } + }, "MediaTypePropertyTypeContainerResponseModel": { "required": [ "id", @@ -40773,6 +40916,45 @@ }, "additionalProperties": false }, + "MemberTypePropertyReferenceResponseModel": { + "required": [ + "$type", + "id", + "memberType" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "nullable": true + }, + "alias": { + "type": "string", + "nullable": true + }, + "memberType": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrackedReferenceMemberTypeModel" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "MemberTypePropertyReferenceResponseModel": "#/components/schemas/MemberTypePropertyReferenceResponseModel" + } + } + }, "MemberTypePropertyTypeContainerResponseModel": { "required": [ "id", @@ -41980,11 +42162,20 @@ { "$ref": "#/components/schemas/DocumentReferenceResponseModel" }, + { + "$ref": "#/components/schemas/DocumentTypePropertyReferenceResponseModel" + }, { "$ref": "#/components/schemas/MediaReferenceResponseModel" }, + { + "$ref": "#/components/schemas/MediaTypePropertyReferenceResponseModel" + }, { "$ref": "#/components/schemas/MemberReferenceResponseModel" + }, + { + "$ref": "#/components/schemas/MemberTypePropertyReferenceResponseModel" } ] } @@ -44639,8 +44830,15 @@ "additionalProperties": false }, "TrackedReferenceDocumentTypeModel": { + "required": [ + "id" + ], "type": "object", "properties": { + "id": { + "type": "string", + "format": "uuid" + }, "icon": { "type": "string", "nullable": true @@ -44657,8 +44855,15 @@ "additionalProperties": false }, "TrackedReferenceMediaTypeModel": { + "required": [ + "id" + ], "type": "object", "properties": { + "id": { + "type": "string", + "format": "uuid" + }, "icon": { "type": "string", "nullable": true @@ -44675,8 +44880,15 @@ "additionalProperties": false }, "TrackedReferenceMemberTypeModel": { + "required": [ + "id" + ], "type": "object", "properties": { + "id": { + "type": "string", + "format": "uuid" + }, "icon": { "type": "string", "nullable": true diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/ContentTypePropertyTypeReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/ContentTypePropertyTypeReferenceResponseModel.cs new file mode 100644 index 0000000000..83dc6a1a7a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/ContentTypePropertyTypeReferenceResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; + +public abstract class ContentTypePropertyTypeReferenceResponseModel : ReferenceResponseModel +{ + public string? Alias { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/DocumentTypePropertyTypeReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/DocumentTypePropertyTypeReferenceResponseModel.cs new file mode 100644 index 0000000000..b2ef3e4a0a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/DocumentTypePropertyTypeReferenceResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; + +public class DocumentTypePropertyTypeReferenceResponseModel : ContentTypePropertyTypeReferenceResponseModel +{ + public TrackedReferenceDocumentType DocumentType { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MediaTypePropertyTypeReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MediaTypePropertyTypeReferenceResponseModel.cs new file mode 100644 index 0000000000..1baf647654 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MediaTypePropertyTypeReferenceResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; + +public class MediaTypePropertyTypeReferenceResponseModel : ContentTypePropertyTypeReferenceResponseModel +{ + public TrackedReferenceMediaType MediaType { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MemberTypePropertyTypeReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MemberTypePropertyTypeReferenceResponseModel.cs new file mode 100644 index 0000000000..199a4b0ba1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/MemberTypePropertyTypeReferenceResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; + +public class MemberTypePropertyTypeReferenceResponseModel : ContentTypePropertyTypeReferenceResponseModel +{ + public TrackedReferenceMemberType MemberType { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/TrackedReferenceContentType.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/TrackedReferenceContentType.cs index 31456abada..15ac365e41 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/TrackedReferenceContentType.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TrackedReferences/TrackedReferenceContentType.cs @@ -1,7 +1,9 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +namespace Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; public abstract class TrackedReferenceContentType { + public Guid Id { get; set; } + public string? Icon { get; set; } public string? Alias { get; set; } diff --git a/src/Umbraco.Core/Constants-ReferenceTypes.cs b/src/Umbraco.Core/Constants-ReferenceTypes.cs new file mode 100644 index 0000000000..b006a0d590 --- /dev/null +++ b/src/Umbraco.Core/Constants-ReferenceTypes.cs @@ -0,0 +1,25 @@ +namespace Umbraco.Cms.Core; + +public static partial class Constants +{ + /// + /// Defines reference types. + /// + /// + /// Reference types are used to identify the type of entity that is being referenced when exposing references + /// between Umbraco entities. + /// These are used in the management API and backoffice to indicate and warn editors when working with an entity, + /// as to what other entities depend on it. + /// These consist of references managed by Umbraco relations (e.g. document, media and member). + /// But also references that come from schema (e.g. data type usage on content types). + /// + public static class ReferenceType + { + public const string Document = UdiEntityType.Document; + public const string Media = UdiEntityType.Media; + public const string Member = UdiEntityType.Member; + public const string DocumentTypePropertyType = "document-type-property-type"; + public const string MediaTypePropertyType = "media-type-property-type"; + public const string MemberTypePropertyType = "member-type-property-type"; + } +} diff --git a/src/Umbraco.Core/Models/RelationItem.cs b/src/Umbraco.Core/Models/RelationItem.cs index a865e7cc2f..41fde0e867 100644 --- a/src/Umbraco.Core/Models/RelationItem.cs +++ b/src/Umbraco.Core/Models/RelationItem.cs @@ -23,6 +23,9 @@ public class RelationItem [DataMember(Name = "published")] public bool? NodePublished { get; set; } + [DataMember(Name = "contentTypeKey")] + public Guid ContentTypeKey { get; set; } + [DataMember(Name = "icon")] public string? ContentTypeIcon { get; set; } diff --git a/src/Umbraco.Core/Models/RelationItemModel.cs b/src/Umbraco.Core/Models/RelationItemModel.cs index a05c8f6591..1ca3bb9e11 100644 --- a/src/Umbraco.Core/Models/RelationItemModel.cs +++ b/src/Umbraco.Core/Models/RelationItemModel.cs @@ -1,15 +1,19 @@ -namespace Umbraco.Cms.Core.Models; +namespace Umbraco.Cms.Core.Models; public class RelationItemModel { public Guid NodeKey { get; set; } + public string? NodeAlias { get; set; } + public string? NodeName { get; set; } public string? NodeType { get; set; } public bool? NodePublished { get; set; } + public Guid ContentTypeKey { get; set; } + public string? ContentTypeIcon { get; set; } public string? ContentTypeAlias { get; set; } diff --git a/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs index ad113533d8..f0babf61f3 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs @@ -24,6 +24,5 @@ public interface IDataTypeRepository : IReadWriteQueryRepository /// /// /// - IReadOnlyDictionary> FindListViewUsages(int id) => throw new NotImplementedException(); } diff --git a/src/Umbraco.Core/Services/DataTypeService.cs b/src/Umbraco.Core/Services/DataTypeService.cs index f22948968a..7bc0b4d95f 100644 --- a/src/Umbraco.Core/Services/DataTypeService.cs +++ b/src/Umbraco.Core/Services/DataTypeService.cs @@ -25,12 +25,15 @@ namespace Umbraco.Cms.Core.Services.Implement private readonly IDataTypeRepository _dataTypeRepository; private readonly IDataTypeContainerRepository _dataTypeContainerRepository; private readonly IContentTypeRepository _contentTypeRepository; + private readonly IMediaTypeRepository _mediaTypeRepository; + private readonly IMemberTypeRepository _memberTypeRepository; private readonly IAuditRepository _auditRepository; private readonly IIOHelper _ioHelper; private readonly IDataTypeContainerService _dataTypeContainerService; private readonly IUserIdKeyResolver _userIdKeyResolver; private readonly Lazy _idKeyMap; + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")] public DataTypeService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -41,12 +44,41 @@ namespace Umbraco.Cms.Core.Services.Implement IContentTypeRepository contentTypeRepository, IIOHelper ioHelper, Lazy idKeyMap) + : this( + provider, + loggerFactory, + eventMessagesFactory, + dataTypeRepository, + dataValueEditorFactory, + auditRepository, + contentTypeRepository, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + ioHelper, + idKeyMap) + { + } + + public DataTypeService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IDataTypeRepository dataTypeRepository, + IDataValueEditorFactory dataValueEditorFactory, + IAuditRepository auditRepository, + IContentTypeRepository contentTypeRepository, + IMediaTypeRepository mediaTypeRepository, + IMemberTypeRepository memberTypeRepository, + IIOHelper ioHelper, + Lazy idKeyMap) : base(provider, loggerFactory, eventMessagesFactory) { _dataValueEditorFactory = dataValueEditorFactory; _dataTypeRepository = dataTypeRepository; _auditRepository = auditRepository; _contentTypeRepository = contentTypeRepository; + _mediaTypeRepository = mediaTypeRepository; + _memberTypeRepository = memberTypeRepository; _ioHelper = ioHelper; _idKeyMap = idKeyMap; @@ -703,12 +735,119 @@ namespace Umbraco.Cms.Core.Services.Implement return await Task.FromResult(Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, usages)); } + /// public IReadOnlyDictionary> GetListViewReferences(int id) { using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); return _dataTypeRepository.FindListViewUsages(id); } + /// + public Task> GetPagedRelationsAsync(Guid key, int skip, int take) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + IDataType? dataType = GetDataTypeFromRepository(key); + if (dataType == null) + { + // Is an unexpected response, but returning an empty collection aligns with how we handle retrieval of concrete Umbraco + // relations based on documents, media and members. + return Task.FromResult(new PagedModel()); + } + + // We don't really need true paging here, as the number of data type relations will be small compared to what there could + // potentially by for concrete Umbraco relations based on documents, media and members. + // So we'll retrieve all usages for the data type and construct a paged response. + // This allows us to re-use the existing repository methods used for FindUsages and FindListViewUsages. + IReadOnlyDictionary> usages = _dataTypeRepository.FindUsages(dataType.Id); + IReadOnlyDictionary> listViewUsages = _dataTypeRepository.FindListViewUsages(dataType.Id); + + // Combine the property and list view usages into a single collection of property aliases and content type UDIs. + IList<(string PropertyAlias, Udi Udi)> combinedUsages = usages + .SelectMany(kvp => kvp.Value.Select(value => (value, kvp.Key))) + .Concat(listViewUsages.SelectMany(kvp => kvp.Value.Select(value => (value, kvp.Key)))) + .ToList(); + + var totalItems = combinedUsages.Count; + + // Create the page of items. + IList<(string PropertyAlias, Udi Udi)> pagedUsages = combinedUsages + .OrderBy(x => x.Udi.EntityType) // Document types first, then media types, then member types. + .ThenBy(x => x.PropertyAlias) + .Skip(skip) + .Take(take) + .ToList(); + + // Get the content types for the UDIs referenced in the page of items to construct the response from. + // They could be document, media or member types. + IList contentTypes = GetReferencedContentTypes(pagedUsages); + + IEnumerable relations = pagedUsages + .Select(x => + { + // Get the matching content type so we can populate the content type and property details. + IContentTypeComposition contentType = contentTypes.Single(y => y.Key == ((GuidUdi)x.Udi).Guid); + + string nodeType = x.Udi.EntityType switch + { + Constants.UdiEntityType.DocumentType => Constants.ReferenceType.DocumentTypePropertyType, + Constants.UdiEntityType.MediaType => Constants.ReferenceType.MediaTypePropertyType, + Constants.UdiEntityType.MemberType => Constants.ReferenceType.MemberTypePropertyType, + _ => throw new ArgumentOutOfRangeException(nameof(x.Udi.EntityType)), + }; + + // Look-up the property details from the property alias. This will be null for a list view reference. + IPropertyType? propertyType = contentType.PropertyTypes.SingleOrDefault(y => y.Alias == x.PropertyAlias); + return new RelationItemModel + { + ContentTypeKey = contentType.Key, + ContentTypeAlias = contentType.Alias, + ContentTypeIcon = contentType.Icon, + ContentTypeName = contentType.Name, + NodeType = nodeType, + NodeName = propertyType?.Name ?? x.PropertyAlias, + NodeAlias = x.PropertyAlias, + NodeKey = propertyType?.Key ?? Guid.Empty, + }; + }); + + var pagedModel = new PagedModel(totalItems, relations); + return Task.FromResult(pagedModel); + } + + private IList GetReferencedContentTypes(IList<(string PropertyAlias, Udi Udi)> pagedUsages) + { + IEnumerable documentTypes = GetContentTypes( + pagedUsages, + Constants.UdiEntityType.DocumentType, + _contentTypeRepository); + IEnumerable mediaTypes = GetContentTypes( + pagedUsages, + Constants.UdiEntityType.MediaType, + _mediaTypeRepository); + IEnumerable memberTypes = GetContentTypes( + pagedUsages, + Constants.UdiEntityType.MemberType, + _memberTypeRepository); + return documentTypes.Concat(mediaTypes).Concat(memberTypes).ToList(); + } + + private static IEnumerable GetContentTypes( + IEnumerable<(string PropertyAlias, Udi Udi)> dataTypeUsages, + string entityType, + IContentTypeRepositoryBase repository) + where T : IContentTypeComposition + { + Guid[] contentTypeKeys = dataTypeUsages + .Where(x => x.Udi is GuidUdi && x.Udi.EntityType == entityType) + .Select(x => ((GuidUdi)x.Udi).Guid) + .Distinct() + .ToArray(); + return contentTypeKeys.Length > 0 + ? repository.GetMany(contentTypeKeys) + : []; + } + /// public IEnumerable ValidateConfigurationData(IDataType dataType) { diff --git a/src/Umbraco.Core/Services/IDataTypeService.cs b/src/Umbraco.Core/Services/IDataTypeService.cs index 3a4576552c..0f2f58ceb8 100644 --- a/src/Umbraco.Core/Services/IDataTypeService.cs +++ b/src/Umbraco.Core/Services/IDataTypeService.cs @@ -18,6 +18,7 @@ public interface IDataTypeService : IService [Obsolete("Please use GetReferencesAsync. Will be deleted in V15.")] IReadOnlyDictionary> GetReferences(int id); + [Obsolete("Please use GetPagedRelationsAsync. Scheduled for removal in Umbraco 17.")] IReadOnlyDictionary> GetListViewReferences(int id) => throw new NotImplementedException(); /// @@ -25,8 +26,25 @@ public interface IDataTypeService : IService /// /// The guid Id of the /// + [Obsolete("Please use GetPagedRelationsAsync. Scheduled for removal in Umbraco 17.")] Task>, DataTypeOperationStatus>> GetReferencesAsync(Guid id); + /// + /// Gets a paged result of items which are in relation with the current data type. + /// + /// The identifier of the data type to retrieve relations for. + /// The amount of items to skip + /// The amount of items to take. + /// A paged result of objects. + /// + /// Note that the model and method signature here aligns with with how we handle retrieval of concrete Umbraco + /// relations based on documents, media and members in . + /// The intention is that we align data type relations with these so they can be handled polymorphically at the management API + /// and backoffice UI level. + /// + Task> GetPagedRelationsAsync(Guid key, int skip, int take) + => Task.FromResult(new PagedModel()); + [Obsolete("Please use IDataTypeContainerService for all data type container operations. Will be removed in V15.")] Attempt?> CreateContainer(int parentId, Guid key, string name, int userId = Constants.Security.SuperUserId); diff --git a/src/Umbraco.Infrastructure/Mapping/RelationModelMapDefinition.cs b/src/Umbraco.Infrastructure/Mapping/RelationModelMapDefinition.cs index 8ace25c07f..1f0c986e0d 100644 --- a/src/Umbraco.Infrastructure/Mapping/RelationModelMapDefinition.cs +++ b/src/Umbraco.Infrastructure/Mapping/RelationModelMapDefinition.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; @@ -19,6 +19,7 @@ public class RelationModelMapDefinition : IMapDefinition target.RelationTypeName = source.RelationTypeName; target.RelationTypeIsBidirectional = source.RelationTypeIsBidirectional; target.RelationTypeIsDependency = source.RelationTypeIsDependency; + target.ContentTypeKey = source.ChildContentTypeKey; target.ContentTypeAlias = source.ChildContentTypeAlias; target.ContentTypeIcon = source.ChildContentTypeIcon; target.ContentTypeName = source.ChildContentTypeName; diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs index a38bf4547f..2786bbfd1e 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs @@ -475,6 +475,9 @@ internal class RelationItemDto [Column(Name = "nodeObjectType")] public Guid ChildNodeObjectType { get; set; } + [Column(Name = "contentTypeKey")] + public Guid ChildContentTypeKey { get; set; } + [Column(Name = "contentTypeIcon")] public string? ChildContentTypeIcon { get; set; } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs index ced64f4996..d9bda52916 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs @@ -35,6 +35,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[n].[text] as nodeName", "[n].[nodeObjectType] as nodeObjectType", "[d].[published] as nodePublished", + "[ctn].[uniqueId] as contentTypeKey", "[ct].[icon] as contentTypeIcon", "[ct].[alias] as contentTypeAlias", "[ctn].[text] as contentTypeName", @@ -188,6 +189,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[n].[text] as nodeName", "[n].[nodeObjectType] as nodeObjectType", "[d].[published] as nodePublished", + "[ctn].[uniqueId] as contentTypeKey", "[ct].[icon] as contentTypeIcon", "[ct].[alias] as contentTypeAlias", "[ctn].[text] as contentTypeName", @@ -250,6 +252,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[n].[text] as nodeName", "[n].[nodeObjectType] as nodeObjectType", "[d].[published] as nodePublished", + "[ctn].[uniqueId] as contentTypeKey", "[ct].[icon] as contentTypeIcon", "[ct].[alias] as contentTypeAlias", "[ctn].[text] as contentTypeName", @@ -336,6 +339,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[n].[text] as nodeName", "[n].[nodeObjectType] as nodeObjectType", "[d].[published] as nodePublished", + "[ctn].[uniqueId] as contentTypeKey", "[ct].[icon] as contentTypeIcon", "[ct].[alias] as contentTypeAlias", "[ctn].[text] as contentTypeName", @@ -411,6 +415,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[n].[uniqueId] as nodeKey", "[n].[text] as nodeName", "[n].[nodeObjectType] as nodeObjectType", + "[ctn].[uniqueId] as contentTypeKey", "[ct].[icon] as contentTypeIcon", "[ct].[alias] as contentTypeAlias", "[ctn].[text] as contentTypeName", @@ -477,6 +482,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[n].[uniqueId] as nodeKey", "[n].[text] as nodeName", "[n].[nodeObjectType] as nodeObjectType", + "[ctn].[uniqueId] as contentTypeKey", "[ct].[icon] as contentTypeIcon", "[ct].[alias] as contentTypeAlias", "[ctn].[text] as contentTypeName", @@ -595,6 +601,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[n].[text] as nodeName", "[n].[nodeObjectType] as nodeObjectType", "[d].[published] as nodePublished", + "[ctn].[uniqueId] as contentTypeKey", "[ct].[icon] as contentTypeIcon", "[ct].[alias] as contentTypeAlias", "[ctn].[text] as contentTypeName", @@ -671,6 +678,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[n].[uniqueId] as nodeKey", "[n].[text] as nodeName", "[n].[nodeObjectType] as nodeObjectType", + "[ctn].[uniqueId] as contentTypeKey", "[ct].[icon] as contentTypeIcon", "[ct].[alias] as contentTypeAlias", "[ctn].[text] as contentTypeName", @@ -749,6 +757,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement "[n].[uniqueId] as nodeKey", "[n].[text] as nodeName", "[n].[nodeObjectType] as nodeObjectType", + "[ctn].[uniqueId] as contentTypeKey", "[ct].[icon] as contentTypeIcon", "[ct].[alias] as contentTypeAlias", "[ctn].[text] as contentTypeName", @@ -841,6 +850,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement RelationTypeName = dto.RelationTypeName, RelationTypeIsBidirectional = dto.RelationTypeIsBidirectional, RelationTypeIsDependency = dto.RelationTypeIsDependency, + ContentTypeKey = dto.ChildContentTypeKey, ContentTypeAlias = dto.ChildContentTypeAlias, ContentTypeIcon = dto.ChildContentTypeIcon, ContentTypeName = dto.ChildContentTypeName, diff --git a/tests/Umbraco.Tests.Common/Builders/MediaTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/MediaTypeBuilder.cs index dcde82b47d..6439899955 100644 --- a/tests/Umbraco.Tests.Common/Builders/MediaTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/MediaTypeBuilder.cs @@ -139,6 +139,7 @@ public class MediaTypeBuilder var mediaType = builder .WithAlias(alias) .WithName(name) + .WithIcon("icon-picture") .WithParentContentType(parent) .AddPropertyGroup() .WithAlias(propertyGroupAlias) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs index 1c2b46137b..92a1567a4f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs @@ -30,6 +30,8 @@ public class DataTypeServiceTests : UmbracoIntegrationTest private IContentTypeService ContentTypeService => GetRequiredService(); + private IMediaTypeService MediaTypeService => GetRequiredService(); + private IFileService FileService => GetRequiredService(); private IConfigurationEditorJsonSerializer ConfigurationEditorJsonSerializer => @@ -446,4 +448,43 @@ public class DataTypeServiceTests : UmbracoIntegrationTest Assert.IsFalse(result.Success); Assert.AreEqual(DataTypeOperationStatus.NonDeletable, result.Status); } + + [Test] + public async Task DataTypeService_Can_Get_References() + { + IEnumerable dataTypeDefinitions = await DataTypeService.GetByEditorAliasAsync(Constants.PropertyEditors.Aliases.RichText); + + IContentType documentType = ContentTypeBuilder.CreateSimpleContentType("umbTextpage", "Text Page"); + ContentTypeService.Save(documentType); + + IMediaType mediaType = MediaTypeBuilder.CreateSimpleMediaType("umbMediaItem", "Media Item"); + MediaTypeService.Save(mediaType); + + documentType = ContentTypeService.Get(documentType.Id); + Assert.IsNotNull(documentType.PropertyTypes.SingleOrDefault(pt => pt.PropertyEditorAlias is Constants.PropertyEditors.Aliases.RichText)); + + mediaType = MediaTypeService.Get(mediaType.Id); + Assert.IsNotNull(mediaType.PropertyTypes.SingleOrDefault(pt => pt.PropertyEditorAlias is Constants.PropertyEditors.Aliases.RichText)); + + var definition = dataTypeDefinitions.First(); + var definitionKey = definition.Key; + PagedModel result = await DataTypeService.GetPagedRelationsAsync(definitionKey, 0, 10); + Assert.AreEqual(2, result.Total); + + RelationItemModel firstResult = result.Items.First(); + Assert.AreEqual("umbTextpage", firstResult.ContentTypeAlias); + Assert.AreEqual("Text Page", firstResult.ContentTypeName); + Assert.AreEqual("icon-document", firstResult.ContentTypeIcon); + Assert.AreEqual(documentType.Key, firstResult.ContentTypeKey); + Assert.AreEqual("bodyText", firstResult.NodeAlias); + Assert.AreEqual("Body text", firstResult.NodeName); + + RelationItemModel secondResult = result.Items.Skip(1).First(); + Assert.AreEqual("umbMediaItem", secondResult.ContentTypeAlias); + Assert.AreEqual("Media Item", secondResult.ContentTypeName); + Assert.AreEqual("icon-picture", secondResult.ContentTypeIcon); + Assert.AreEqual(mediaType.Key, secondResult.ContentTypeKey); + Assert.AreEqual("bodyText", secondResult.NodeAlias); + Assert.AreEqual("Body text", secondResult.NodeName); + } } From 4b016317f97922d2e35ef500eaa15a0690107608 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Fri, 4 Apr 2025 17:34:08 +0200 Subject: [PATCH 23/53] Skip lock tests --- .../Umbraco.Infrastructure/Persistence/LocksTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/LocksTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/LocksTests.cs index 0f373088e4..3efc2b27a2 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/LocksTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/LocksTests.cs @@ -527,7 +527,7 @@ public class LocksTests : UmbracoIntegrationTest } } - [Retry(3)] // TODO make this test non-flaky. + [NUnit.Framework.Ignore("This test is very flaky, and is stopping our nightlys")] [Test] public void Read_Lock_Waits_For_Write_Lock() { From 4078e83634144bfa0b2f127b875d695ffcfea74b Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 7 Apr 2025 08:18:06 +0200 Subject: [PATCH 24/53] Look-up redirect in content finder for multi-lingual sites using path and legacy route prefixed with the integer ID of the node with domains defined (#18763) * Look-up redirect in content finder for multi-lingual sites using path and legacy route prefixed with the integer ID of the node with domains defined. * Added tests to verify functionality. * Added reference to previous PR. * Referenced second PR. * Assemble URLs for all cultures, not just the default. * Revert previous update. * Display an original URL if we have one. --- .../RedirectUrlPresentationFactory.cs | 8 +- .../Routing/ContentFinderByRedirectUrl.cs | 31 +++- .../Routing/NewDefaultUrlProvider.cs | 3 +- .../ContentFinderByRedirectUrlTests.cs | 162 ++++++++++++++++++ 4 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByRedirectUrlTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Factories/RedirectUrlPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/RedirectUrlPresentationFactory.cs index 8a62d299eb..8cb09bf03a 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/RedirectUrlPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/RedirectUrlPresentationFactory.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.RedirectUrlManagement; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Routing; @@ -22,6 +22,12 @@ public class RedirectUrlPresentationFactory : IRedirectUrlPresentationFactory var originalUrl = _publishedUrlProvider.GetUrlFromRoute(source.ContentId, source.Url, source.Culture); + // Even if the URL could not be extracted from the route, if we have a path as a the route for the original URL, we should display it. + if (originalUrl == "#" && source.Url.StartsWith('/')) + { + originalUrl = source.Url; + } + return new RedirectUrlResponseModel { OriginalUrl = originalUrl, diff --git a/src/Umbraco.Core/Routing/ContentFinderByRedirectUrl.cs b/src/Umbraco.Core/Routing/ContentFinderByRedirectUrl.cs index c6f8a49fcd..adc367a97f 100644 --- a/src/Umbraco.Core/Routing/ContentFinderByRedirectUrl.cs +++ b/src/Umbraco.Core/Routing/ContentFinderByRedirectUrl.cs @@ -53,21 +53,38 @@ public class ContentFinderByRedirectUrl : IContentFinder return false; } - var route = frequest.Domain != null - ? frequest.Domain.ContentId + - DomainUtilities.PathRelativeToDomain(frequest.Domain.Uri, frequest.AbsolutePathDecoded) - : frequest.AbsolutePathDecoded; - + var route = frequest.AbsolutePathDecoded; IRedirectUrl? redirectUrl = await _redirectUrlService.GetMostRecentRedirectUrlAsync(route, frequest.Culture); - if (redirectUrl == null) + if (redirectUrl is null) { if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("No match for route: {Route}", route); } - return false; + // Routes under domains can be stored with the integer ID of the content where the domains were defined as the first part of the route, + // so if we haven't found a redirect, try using that format too. + // See: https://github.com/umbraco/Umbraco-CMS/pull/18160 and https://github.com/umbraco/Umbraco-CMS/pull/18763 + if (frequest.Domain is not null) + { + route = frequest.Domain.ContentId + DomainUtilities.PathRelativeToDomain(frequest.Domain.Uri, frequest.AbsolutePathDecoded); + redirectUrl = await _redirectUrlService.GetMostRecentRedirectUrlAsync(route, frequest.Culture); + + if (redirectUrl is null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("No match for route with domain: {Route}", route); + } + + return false; + } + } + else + { + return false; + } } IPublishedContent? content = umbracoContext.Content?.GetById(redirectUrl.ContentId); diff --git a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs index 314f2c5598..c3e779b0d7 100644 --- a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs @@ -249,7 +249,8 @@ public class NewDefaultUrlProvider : IUrlProvider culture); var defaultCulture = _localizationService.GetDefaultLanguageIsoCode(); - if (domainUri is not null || string.IsNullOrEmpty(culture) || + if (domainUri is not null || + string.IsNullOrEmpty(culture) || culture.Equals(defaultCulture, StringComparison.InvariantCultureIgnoreCase)) { var url = AssembleUrl(domainUri, path, current, mode).ToString(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByRedirectUrlTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByRedirectUrlTests.cs new file mode 100644 index 0000000000..025a1ef655 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByRedirectUrlTests.cs @@ -0,0 +1,162 @@ +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Routing; + +[TestFixture] +public class ContentFinderByRedirectUrlTests +{ + private const int DomainContentId = 1233; + private const int ContentId = 1234; + + [Test] + public async Task Can_Find_Invariant_Content() + { + const string OldPath = "/old-page-path"; + const string NewPath = "/new-page-path"; + + var mockRedirectUrlService = CreateMockRedirectUrlService(OldPath); + + var mockContent = CreateMockPublishedContent(); + + var mockUmbracoContextAccessor = CreateMockUmbracoContextAccessor(mockContent); + + var mockPublishedUrlProvider = CreateMockPublishedUrlProvider(NewPath); + + var sut = CreateContentFinder(mockRedirectUrlService, mockUmbracoContextAccessor, mockPublishedUrlProvider); + + var publishedRequestBuilder = CreatePublishedRequestBuilder(OldPath); + + var result = await sut.TryFindContent(publishedRequestBuilder); + + AssertRedirectResult(publishedRequestBuilder, result); + } + + [Test] + public async Task Can_Find_Variant_Content_With_Path_Root() + { + const string OldPath = "/en/old-page-path"; + const string NewPath = "/en/new-page-path"; + + var mockRedirectUrlService = CreateMockRedirectUrlService(OldPath); + + var mockContent = CreateMockPublishedContent(); + + var mockUmbracoContextAccessor = CreateMockUmbracoContextAccessor(mockContent); + + var mockPublishedUrlProvider = CreateMockPublishedUrlProvider(NewPath); + + var sut = CreateContentFinder(mockRedirectUrlService, mockUmbracoContextAccessor, mockPublishedUrlProvider); + + var publishedRequestBuilder = CreatePublishedRequestBuilder(OldPath, withDomain: true); + + var result = await sut.TryFindContent(publishedRequestBuilder); + + AssertRedirectResult(publishedRequestBuilder, result); + } + + [Test] + public async Task Can_Find_Variant_Content_With_Domain_Node_Id_Prefixed_Path() + { + const string OldPath = "/en/old-page-path"; + var domainPrefixedOldPath = $"{DomainContentId}/old-page-path"; + const string NewPath = "/en/new-page-path"; + + var mockRedirectUrlService = CreateMockRedirectUrlService(domainPrefixedOldPath); + + var mockContent = CreateMockPublishedContent(); + + var mockUmbracoContextAccessor = CreateMockUmbracoContextAccessor(mockContent); + + var mockPublishedUrlProvider = CreateMockPublishedUrlProvider(NewPath); + + var sut = CreateContentFinder(mockRedirectUrlService, mockUmbracoContextAccessor, mockPublishedUrlProvider); + + var publishedRequestBuilder = CreatePublishedRequestBuilder(OldPath, withDomain: true); + + var result = await sut.TryFindContent(publishedRequestBuilder); + + AssertRedirectResult(publishedRequestBuilder, result); + } + + private static Mock CreateMockRedirectUrlService(string oldPath) + { + var mockRedirectUrlService = new Mock(); + mockRedirectUrlService + .Setup(x => x.GetMostRecentRedirectUrlAsync(It.Is(y => y == oldPath), It.IsAny())) + .ReturnsAsync(new RedirectUrl + { + ContentId = ContentId, + }); + return mockRedirectUrlService; + } + + private static Mock CreateMockPublishedUrlProvider(string newPath) + { + var mockPublishedUrlProvider = new Mock(); + mockPublishedUrlProvider + .Setup(x => x.GetUrl(It.Is(y => y.Id == ContentId), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(newPath); + return mockPublishedUrlProvider; + } + + private static Mock CreateMockPublishedContent() + { + var mockContent = new Mock(); + mockContent + .SetupGet(x => x.Id) + .Returns(ContentId); + mockContent + .SetupGet(x => x.ContentType.ItemType) + .Returns(PublishedItemType.Content); + return mockContent; + } + + private static Mock CreateMockUmbracoContextAccessor(Mock mockContent) + { + var mockUmbracoContext = new Mock(); + mockUmbracoContext + .Setup(x => x.Content.GetById(It.Is(y => y == ContentId))) + .Returns(mockContent.Object); + var mockUmbracoContextAccessor = new Mock(); + var umbracoContext = mockUmbracoContext.Object; + mockUmbracoContextAccessor + .Setup(x => x.TryGetUmbracoContext(out umbracoContext)) + .Returns(true); + return mockUmbracoContextAccessor; + } + + private static ContentFinderByRedirectUrl CreateContentFinder( + Mock mockRedirectUrlService, + Mock mockUmbracoContextAccessor, + Mock mockPublishedUrlProvider) + => new ContentFinderByRedirectUrl( + mockRedirectUrlService.Object, + new NullLogger(), + mockPublishedUrlProvider.Object, + mockUmbracoContextAccessor.Object); + + private static PublishedRequestBuilder CreatePublishedRequestBuilder(string path, bool withDomain = false) + { + var publishedRequestBuilder = new PublishedRequestBuilder(new Uri($"https://example.com{path}"), Mock.Of()); + if (withDomain) + { + publishedRequestBuilder.SetDomain(new DomainAndUri(new Domain(1, "/en", DomainContentId, "en-US", false, 0), new Uri($"https://example.com{path}"))); + } + + return publishedRequestBuilder; + } + + private static void AssertRedirectResult(PublishedRequestBuilder publishedRequestBuilder, bool result) + { + Assert.AreEqual(true, result); + Assert.AreEqual(HttpStatusCode.Moved, (HttpStatusCode)publishedRequestBuilder.ResponseStatusCode); + } +} From 1d9a031c1cda554d633adec06ea036c412e2aaf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Apr 2025 16:01:35 +0000 Subject: [PATCH 25/53] Bump vite from 6.2.4 to 6.2.5 in /src/Umbraco.Web.UI.Client Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.2.4 to 6.2.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v6.2.5/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v6.2.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 6.2.5 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- src/Umbraco.Web.UI.Client/package-lock.json | 8 ++++---- src/Umbraco.Web.UI.Client/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 8bc9988b65..1bd936cecb 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -94,7 +94,7 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.24.1", "typescript-json-schema": "^0.65.1", - "vite": "^6.2.4", + "vite": "^6.2.5", "vite-plugin-static-copy": "^2.2.0", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" @@ -16885,9 +16885,9 @@ } }, "node_modules/vite": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz", - "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", + "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 9c8ec90343..66472a6c32 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -284,7 +284,7 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.24.1", "typescript-json-schema": "^0.65.1", - "vite": "^6.2.4", + "vite": "^6.2.5", "vite-plugin-static-copy": "^2.2.0", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" From ae423decc3e3e6d84d79fccc4f9cd0706a024601 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Mon, 7 Apr 2025 09:40:17 +0200 Subject: [PATCH 26/53] Add raw value validation to multiple text strings property editor (#18936) * Add raw value validation to multiple text strings property editor * Added additional assert on unit test and comment on validation logic. * Don't remove items to obtain a valid value --------- Co-authored-by: Andy Butland --- .../MultipleTextStringPropertyEditor.cs | 34 ++++------ ...tipleTextStringPropertyValueEditorTests.cs | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 23 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs index 9fe8dbe4ec..714d1e624b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs @@ -89,21 +89,6 @@ public class MultipleTextStringPropertyEditor : DataEditor return null; } - if (!(editorValue.DataTypeConfiguration is MultipleTextStringConfiguration config)) - { - throw new PanicException( - $"editorValue.DataTypeConfiguration is {editorValue.DataTypeConfiguration?.GetType()} but must be {typeof(MultipleTextStringConfiguration)}"); - } - - var max = config.Max; - - // The legacy property editor saved this data as new line delimited! strange but we have to maintain that. - // only allow the max if over 0 - if (max > 0) - { - return string.Join(_newLine, value.Take(max)); - } - return string.Join(_newLine, value); } @@ -114,9 +99,12 @@ public class MultipleTextStringPropertyEditor : DataEditor // The legacy property editor saved this data as new line delimited! strange but we have to maintain that. return value is string stringValue - ? stringValue.Split(_newLineDelimiters, StringSplitOptions.None) + ? SplitPropertyValue(stringValue) : Array.Empty(); } + + internal static string[] SplitPropertyValue(string propertyValue) + => propertyValue.Split(_newLineDelimiters, StringSplitOptions.None); } /// @@ -166,13 +154,13 @@ public class MultipleTextStringPropertyEditor : DataEditor yield break; } - // If we have a null value, treat as an empty collection for minimum number validation. - if (value is not IEnumerable stringValues) - { - stringValues = []; - } - - var stringCount = stringValues.Count(); + // Handle both a newline delimited string and an IEnumerable as the value (see: https://github.com/umbraco/Umbraco-CMS/pull/18936). + // If we have a null value, treat as a string count of zero for minimum number validation. + var stringCount = value is string stringValue + ? MultipleTextStringPropertyValueEditor.SplitPropertyValue(stringValue).Length + : value is IEnumerable strings + ? strings.Count() + : 0; if (stringCount < multipleTextStringConfiguration.Min) { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringPropertyValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringPropertyValueEditorTests.cs index c3a814075a..e7544fe08d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringPropertyValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringPropertyValueEditorTests.cs @@ -82,6 +82,20 @@ public class MultipleTextStringPropertyValueEditorTests Assert.AreEqual("The First Value\nThe Second Value\nThe Third Value", fromEditor); } + [Test] + public void Can_Parse_More_Items_Than_Allowed_From_Editor() + { + var valueEditor = CreateValueEditor(); + var fromEditor = valueEditor.FromEditor(new ContentPropertyData(new[] { "One", "Two", "Three", "Four", "Five" }, new MultipleTextStringConfiguration { Max = 4 }), null) as string; + Assert.AreEqual("One\nTwo\nThree\nFour\nFive", fromEditor); + + var validationResults = valueEditor.Validate(fromEditor, false, null, PropertyValidationContext.Empty()); + Assert.AreEqual(1, validationResults.Count()); + + var validationResult = validationResults.First(); + Assert.AreEqual($"validation_outOfRangeMultipleItemsMaximum", validationResult.ErrorMessage); + } + [Test] public void Can_Parse_Single_Value_To_Editor() { @@ -150,6 +164,27 @@ public class MultipleTextStringPropertyValueEditorTests } } + [TestCase("", false)] + [TestCase("one", false)] + [TestCase("one\ntwo", true)] + [TestCase("one\ntwo\nthree", true)] + public void Validates_Number_Of_Items_Is_Greater_Than_Or_Equal_To_Configured_Min_Raw_Property_Value(string value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual($"validation_outOfRangeSingleItemMinimum", validationResult.ErrorMessage); + } + } + [TestCase(3, true)] [TestCase(4, true)] [TestCase(5, false)] @@ -171,6 +206,36 @@ public class MultipleTextStringPropertyValueEditorTests } } + [TestCase("one\ntwo\nthree", true)] + [TestCase("one\ntwo\nthree\nfour", true)] + [TestCase("one\ntwo\nthree\nfour\nfive", false)] + public void Validates_Number_Of_Items_Is_Less_Than_Or_Equal_To_Configured_Max_Raw_Property_Value(string value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual("validation_outOfRangeMultipleItemsMaximum", validationResult.ErrorMessage); + } + } + + [TestCase("one\ntwo\nthree")] + [TestCase("one\rtwo\rthree")] + [TestCase("one\r\ntwo\r\nthree")] + public void Can_Parse_Supported_Property_Value_Delimiters(string value) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + Assert.IsEmpty(result); + } + [Test] public void Max_Item_Validation_Respects_0_As_Unlimited() { From 3d1e17b07f82e78d3a748bcfb9bcbefb70769cb4 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 7 Apr 2025 10:04:11 +0200 Subject: [PATCH 27/53] Integration tests for content publishing with ancestor unpublished (#18941) * Resolved warnings in test class. * Refactor regions into partial classes. * Aligned test names. * Variable name refactoring. * Added tests for unpublished paths. * Adjust tests to verify current behaviour. * Cleaned up project file. --- .../Builders/ContentTypeBaseBuilder.cs | 14 +- .../Builders/ContentTypeBuilder.cs | 3 +- ...entPublishingServiceTests.ClearSchedule.cs | 193 +++ .../ContentPublishingServiceTests.Publish.cs | 280 ++++ ...tPublishingServiceTests.SchedulePublish.cs | 258 +++ ...ublishingServiceTests.ScheduleUnpublish.cs | 221 +++ ...ublishingServiceTests.UnschedulePublish.cs | 248 +++ ...lishingServiceTests.UnscheduleUnpublish.cs | 247 +++ .../Services/ContentPublishingServiceTests.cs | 1405 +---------------- .../Umbraco.Tests.Integration.csproj | 18 + 10 files changed, 1517 insertions(+), 1370 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ClearSchedule.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.Publish.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.SchedulePublish.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ScheduleUnpublish.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnschedulePublish.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnscheduleUnpublish.cs diff --git a/tests/Umbraco.Tests.Common/Builders/ContentTypeBaseBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentTypeBaseBuilder.cs index a5d3597662..e01d87aa0e 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentTypeBaseBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentTypeBaseBuilder.cs @@ -1,8 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; -using System.Collections.Generic; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Tests.Common.Builders.Extensions; @@ -29,7 +27,8 @@ public abstract class ContentTypeBaseBuilder IWithIconBuilder, IWithThumbnailBuilder, IWithTrashedBuilder, - IWithIsContainerBuilder + IWithIsContainerBuilder, + IWithAllowAsRootBuilder where TParent : IBuildContentTypes { private string _alias; @@ -49,6 +48,7 @@ public abstract class ContentTypeBaseBuilder private string _thumbnail; private bool? _trashed; private DateTime? _updateDate; + private bool? _allowedAtRoot; public ContentTypeBaseBuilder(TParent parentBuilder) : base(parentBuilder) @@ -168,6 +168,12 @@ public abstract class ContentTypeBaseBuilder set => _updateDate = value; } + bool? IWithAllowAsRootBuilder.AllowAsRoot + { + get => _allowedAtRoot; + set => _allowedAtRoot = value; + } + protected int GetId() => _id ?? 0; protected Guid GetKey() => _key ?? Guid.NewGuid(); @@ -202,6 +208,8 @@ public abstract class ContentTypeBaseBuilder protected Guid? GetListView() => _listView; + protected bool GetAllowedAtRoot() => _allowedAtRoot ?? false; + protected void BuildPropertyGroups(ContentTypeCompositionBase contentType, IEnumerable propertyGroups) { foreach (var propertyGroup in propertyGroups) diff --git a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs index 65c2b85d79..54e9090ddb 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs @@ -1,8 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; @@ -125,6 +123,7 @@ public class ContentTypeBuilder contentType.Trashed = GetTrashed(); contentType.ListView = GetListView(); contentType.IsElement = _isElement ?? false; + contentType.AllowedAsRoot = GetAllowedAtRoot(); contentType.HistoryCleanup = new HistoryCleanup(); contentType.Variations = contentVariation; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ClearSchedule.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ClearSchedule.cs new file mode 100644 index 0000000000..d639190cfa --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ClearSchedule.cs @@ -0,0 +1,193 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_Clear_Schedule_Invariant() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishInvariantAsync(content); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var clearScheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = Constants.System.InvariantCulture, + Schedule = new ContentScheduleModel(), + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(clearScheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsNull(content!.PublishDate); + Assert.AreEqual(0, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Clear_Schedule_Single_Culture() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel(), + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(4, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Clear_Schedule_Some_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel(), + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel(), + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(2, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Clear_Schedule_All_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel(), + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel(), + }, + new() + { + Culture = setupInfo.LangBe.IsoCode, + Schedule = new ContentScheduleModel(), + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual(0, schedules.FullSchedule.Count); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.Publish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.Publish.cs new file mode 100644 index 0000000000..9e1669ae35 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.Publish.cs @@ -0,0 +1,280 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_Publish_Single_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var setupData = await CreateVariantContentAsync(langEn, langDa, langBe, contentType); + + var publishAttempt = await ContentPublishingService.PublishAsync( + setupData.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + var content = ContentService.GetById(setupData.Key); + Assert.AreEqual(1, content!.PublishedCultures.Count()); + } + + [Test] + public async Task Can_Publish_Some_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync(langEn, langDa, langBe, contentType); + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() { Culture = langEn.IsoCode }, new() { Culture = langDa.IsoCode }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + content = ContentService.GetById(content.Key); + Assert.AreEqual(2, content!.PublishedCultures.Count()); + } + + [Test] + public async Task Can_Publish_All_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync(langEn, langDa, langBe, contentType); + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + new() { Culture = langBe.IsoCode }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + content = ContentService.GetById(content.Key); + Assert.AreEqual(3, content!.PublishedCultures.Count()); + } + + [Test] + public async Task Cannot_Publish_Invariant_In_Variant_Setup() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant, publishAttempt.Status); + + content = ContentService.GetById(content.Key); + Assert.AreEqual(0, content!.PublishedCultures.Count()); + } + + [Test] + public async Task Can_Publish_Invariant_In_Invariant_Setup() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + + content = ContentService.GetById(content.Key); + Assert.NotNull(content!.PublishDate); + } + + [Test] + public async Task Cannot_Publish_Unknown_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + new() { Culture = UnknownCulture }, + ], + Constants.Security.SuperUserKey); + + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, publishAttempt.Status); + + content = ContentService.GetById(content.Key); + Assert.AreEqual(0, content!.PublishedCultures.Count()); + } + + [Test] + public async Task Cannot_Publish_Scheduled_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + } + ], + Constants.Security.SuperUserKey); + + if (scheduleAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var publishAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.CultureAwaitingRelease, publishAttempt.Status); + + content = ContentService.GetById(content.Key); + Assert.AreEqual(0, content!.PublishedCultures.Count()); + } + + // TODO: The following three tests verify existing functionality that could be reconsidered. + // The existing behaviour, verified on Umbraco 13 and 15 is as follows: + // - For invariant content, if a parent is unpublished and I try to publish the child, I get a ContentPublishingOperationStatus.PathNotPublished error. + // - For variant content, if I publish the parent in English but not Danish, I can publish the child in Danish. + // This is inconsistent so we should consider if this is the desired behaviour. + // For now though, the following tests verify the existing behaviour. + + [Test] + public async Task Cannot_Publish_With_Unpublished_Parent() + { + var doctype = await SetupInvariantDoctypeAsync(); + var parentContent = await CreateInvariantContentAsync(doctype); + var childContent = await CreateInvariantContentAsync(doctype, parentContent.Key); + + var publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.PathNotPublished, publishAttempt.Status); + + // Now publish the parent and re-try publishing the child. + publishAttempt = await ContentPublishingService.PublishAsync( + parentContent.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + + publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + } + + [Test] + public async Task Cannot_Publish_Culture_With_Unpublished_Parent() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var parentContent = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + var childContent = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType, + parentContent.Key); + + // Publish child in English, should not succeed. + var publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsFalse(publishAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.PathNotPublished, publishAttempt.Status); + + // Now publish the parent and re-try publishing the child. + publishAttempt = await ContentPublishingService.PublishAsync( + parentContent.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + + publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + } + + [Test] + public async Task Can_Publish_Culture_With_Unpublished_Parent_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var parentContent = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + var childContent = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType, + parentContent.Key); + + // Publish parent in English. + var publishAttempt = await ContentPublishingService.PublishAsync( + parentContent.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + + // Publish child in English, should succeed. + publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + + // Publish child in Danish, should also succeed. + publishAttempt = await ContentPublishingService.PublishAsync( + childContent.Key, + [new() { Culture = langDa.IsoCode }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.SchedulePublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.SchedulePublish.cs new file mode 100644 index 0000000000..e3e0a65f2a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.SchedulePublish.cs @@ -0,0 +1,258 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_Schedule_Publish_Invariant() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = Constants.System.InvariantCulture, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsNull(content!.PublishDate); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Publish_Single_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Publish_Some_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langDa.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual(2, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Publish_All_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langBe.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langDa.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langBe.IsoCode, ContentScheduleAction.Release).Single().Date); + Assert.AreEqual(3, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Cannot_Schedule_Publish_Unknown_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = UnknownCulture, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsFalse(scheduleAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual(0, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Publish_And_Schedule_Different_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(1, content!.PublishedCultures.Count()); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ScheduleUnpublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ScheduleUnpublish.cs new file mode 100644 index 0000000000..c49f8684ab --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.ScheduleUnpublish.cs @@ -0,0 +1,221 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_Schedule_Unpublish_Invariant() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = Constants.System.InvariantCulture, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsNull(content!.PublishDate); + Assert.AreEqual( + _scheduleUnPublishDate, + schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Unpublish_Single_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Unpublish_Some_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langDa.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual(2, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Schedule_Unpublish_All_Cultures() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langBe.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langEn.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langDa.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual( + _schedulePublishDate, + schedules.GetSchedule(langBe.IsoCode, ContentScheduleAction.Expire).Single().Date); + Assert.AreEqual(3, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Cannot_Schedule_Unpublish_Unknown_Culture() + { + var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + langEn, + langDa, + langBe, + contentType); + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = langEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + new() + { + Culture = langDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, + }, + new() + { + Culture = UnknownCulture, + Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate } + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsFalse(scheduleAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual(0, schedules.FullSchedule.Count); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnschedulePublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnschedulePublish.cs new file mode 100644 index 0000000000..c4d0ab1c1e --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnschedulePublish.cs @@ -0,0 +1,248 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_UnSchedule_Publish_Invariant() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishInvariantAsync(content); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var unscheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = Constants.System.InvariantCulture, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(unscheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsNull(content!.PublishDate); + Assert.IsFalse(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Any()); + Assert.IsTrue(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Publish_Single_Culture() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); + Assert.AreEqual(5, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Publish_Some_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate } + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate } + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); + Assert.AreEqual(4, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Publish_All_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + new() + { + Culture = setupInfo.LangBe.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Release).Any()); + Assert.AreEqual(3, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Cannot_Unschedule_Publish_Unknown_Culture() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + new() + { + Culture = UnknownCulture, + Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsFalse(scheduleAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual(6, schedules.FullSchedule.Count); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnscheduleUnpublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnscheduleUnpublish.cs new file mode 100644 index 0000000000..3e3a5ea61a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.UnscheduleUnpublish.cs @@ -0,0 +1,247 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +{ + [Test] + public async Task Can_UnSchedule_Unpublish_Invariant() + { + var doctype = await SetupInvariantDoctypeAsync(); + var content = await CreateInvariantContentAsync(doctype); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishInvariantAsync(content); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var unscheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = Constants.System.InvariantCulture, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(unscheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsNull(content!.PublishDate); + Assert.IsFalse(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Any()); + Assert.IsTrue(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Any()); + Assert.AreEqual(1, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Unpublish_Single_Culture() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(5, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Unpublish_Some_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(4, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Can_Unschedule_Unpublish_All_Cultures() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = setupInfo.LangBe.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(scheduleAttempt.Success); + + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.IsFalse(schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Expire).Any()); + Assert.AreEqual(3, schedules.FullSchedule.Count); + }); + } + + [Test] + public async Task Cannot_Unschedule_Unpublish_Unknown_Culture() + { + var setupInfo = await SetupVariantDoctypeAsync(); + var content = await CreateVariantContentAsync( + setupInfo.LangEn, + setupInfo.LangDa, + setupInfo.LangBe, + setupInfo.contentType); + + var scheduleSetupAttempt = + await SchedulePublishAndUnPublishForAllCulturesAsync(content, setupInfo); + + if (scheduleSetupAttempt.Success is false) + { + throw new Exception("Setup failed"); + } + + var scheduleAttempt = await ContentPublishingService.PublishAsync( + content.Key, + [ + new() + { + Culture = setupInfo.LangEn.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = setupInfo.LangDa.IsoCode, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + new() + { + Culture = UnknownCulture, + Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, + }, + ], + Constants.Security.SuperUserKey); + + Assert.IsFalse(scheduleAttempt.Success); + Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); + var schedules = ContentService.GetContentScheduleByContentId(content.Id); + content = ContentService.GetById(content.Key); + + Assert.Multiple(() => + { + Assert.AreEqual(0, content!.PublishedCultures.Count()); + Assert.AreEqual(6, schedules.FullSchedule.Count); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.cs index 050b08582c..d1d283612c 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.cs @@ -18,7 +18,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; Database = UmbracoTestOptions.Database.NewSchemaPerTest, PublishedRepositoryEvents = true, WithApplication = true)] -public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent +public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent { private const string UnknownCulture = "ke-Ke"; @@ -39,1345 +39,7 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent private IContentEditingService ContentEditingService => GetRequiredService(); - #region Publish - - [Test] - public async Task Can_Publish_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List { new() { Culture = setupInfo.LangEn.IsoCode } }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(publishAttempt.Success); - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(1, content!.PublishedCultures.Count()); - } - - [Test] - public async Task Can_Publish_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() { Culture = setupInfo.LangEn.IsoCode }, new() { Culture = setupInfo.LangDa.IsoCode }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(publishAttempt.Success); - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(2, content!.PublishedCultures.Count()); - } - - [Test] - public async Task Can_Publish_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() { Culture = setupInfo.LangEn.IsoCode }, - new() { Culture = setupInfo.LangDa.IsoCode }, - new() { Culture = setupInfo.LangBe.IsoCode }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(publishAttempt.Success); - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(3, content!.PublishedCultures.Count()); - } - - [Test] - public async Task Can_NOT_Publish_Invariant_In_Variant_Setup() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List { new() { Culture = Constants.System.InvariantCulture } }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(publishAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant, publishAttempt.Status); - - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(0, content!.PublishedCultures.Count()); - } - - [Test] - public async Task Can_Publish_Invariant_In_Invariant_Setup() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List { new() { Culture = Constants.System.InvariantCulture } }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(publishAttempt.Success); - - var content = ContentService.GetById(setupData.Key); - Assert.NotNull(content!.PublishDate); - } - //todo more tests for invariant - //todo update schedule date - - [Test] - public async Task Can_NOT_Publish_Unknown_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() { Culture = setupInfo.LangEn.IsoCode }, - new() { Culture = setupInfo.LangDa.IsoCode }, - new() { Culture = UnknownCulture }, - }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(publishAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, publishAttempt.Status); - - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(0, content!.PublishedCultures.Count()); - } - - [Test] - public async Task Can_NOT_Publish_Scheduled_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - } - }, - Constants.Security.SuperUserKey); - - if (scheduleAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var publishAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List { new() { Culture = setupInfo.LangEn.IsoCode } }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(publishAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.CultureAwaitingRelease, publishAttempt.Status); - - var content = ContentService.GetById(setupData.Key); - Assert.AreEqual(0, content!.PublishedCultures.Count()); - } - - #endregion - - #region Schedule Publish - - [Test] - public async Task Can_Schedule_Publish_Invariant() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = Constants.System.InvariantCulture, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsNull(content!.PublishDate); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Publish_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Publish_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual(2, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Publish_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangBe.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Release).Single().Date); - Assert.AreEqual(3, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_NOT_Schedule_Publish_Unknown_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = UnknownCulture, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(scheduleAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual(0, schedules.FullSchedule.Count); - }); - } - - #endregion - - #region Schedule Unpublish - - [Test] - public async Task Can_Schedule_Unpublish_Invariant() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = Constants.System.InvariantCulture, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsNull(content!.PublishDate); - Assert.AreEqual( - _scheduleUnPublishDate, - schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Unpublish_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Unpublish_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual(2, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Schedule_Unpublish_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangBe.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual( - _schedulePublishDate, - schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Expire).Single().Date); - Assert.AreEqual(3, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_NOT_Schedule_Unpublish_Unknown_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate }, - }, - new() - { - Culture = UnknownCulture, - Schedule = new ContentScheduleModel { UnpublishDate = _schedulePublishDate } - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(scheduleAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual(0, schedules.FullSchedule.Count); - }); - } - - #endregion - - #region Unschedule Publish - - [Test] - public async Task Can_UnSchedule_Publish_Invariant() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishInvariantAsync(setupData); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var unscheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = Constants.System.InvariantCulture, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(unscheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsNull(content!.PublishDate); - Assert.IsFalse(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Any()); - Assert.IsTrue(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Publish_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); - Assert.AreEqual(5, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Publish_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate } - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate } - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); - Assert.AreEqual(4, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Publish_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - new() - { - Culture = setupInfo.LangBe.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Release).Any()); - Assert.AreEqual(3, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_NOT_Unschedule_Publish_Unknown_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - new() - { - Culture = UnknownCulture, - Schedule = new ContentScheduleModel { UnpublishDate = _scheduleUnPublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(scheduleAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual(6, schedules.FullSchedule.Count); - }); - } - - #endregion - - #region Unschedule Unpublish - - [Test] - public async Task Can_UnSchedule_Unpublish_Invariant() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishInvariantAsync(setupData); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var unscheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = Constants.System.InvariantCulture, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(unscheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsNull(content!.PublishDate); - Assert.IsFalse(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Expire).Any()); - Assert.IsTrue(schedules.GetSchedule(Constants.System.InvariantCulture, ContentScheduleAction.Release).Any()); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Unpublish_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(5, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Unpublish_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate } - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(4, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Unschedule_Unpublish_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangBe.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangBe.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(3, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_NOT_Unschedule_Unpublish_Unknown_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - new() - { - Culture = UnknownCulture, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsFalse(scheduleAttempt.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InvalidCulture, scheduleAttempt.Status); - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual(6, schedules.FullSchedule.Count); - }); - } - - #endregion - - #region Clean Schedule - - [Test] - public async Task Can_Clear_Schedule_Invariant() - { - var doctype = await SetupInvariantDoctypeAsync(); - var setupData = await CreateInvariantContentAsync(doctype); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishInvariantAsync(setupData); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var clearScheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = Constants.System.InvariantCulture, - Schedule = new ContentScheduleModel(), - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(clearScheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsNull(content!.PublishDate); - Assert.AreEqual(0, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Clear_Schedule_Single_Culture() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel(), - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(4, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Clear_Schedule_Some_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel(), - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel(), - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangEn.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Release).Any()); - Assert.IsFalse(schedules.GetSchedule(setupInfo.LangDa.IsoCode, ContentScheduleAction.Expire).Any()); - Assert.AreEqual(2, schedules.FullSchedule.Count); - }); - } - - [Test] - public async Task Can_Clear_Schedule_All_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleSetupAttempt = - await SchedulePublishAndUnPublishForAllCulturesAsync(setupData, setupInfo); - - if (scheduleSetupAttempt.Success is false) - { - throw new Exception("Setup failed"); - } - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - Schedule = new ContentScheduleModel(), - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel(), - }, - new() - { - Culture = setupInfo.LangBe.IsoCode, - Schedule = new ContentScheduleModel(), - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(0, content!.PublishedCultures.Count()); - Assert.AreEqual(0, schedules.FullSchedule.Count); - }); - } - - #endregion - - #region Combinations - - [Test] - public async Task Can_Publish_And_Schedule_Different_Cultures() - { - var setupInfo = await SetupVariantDoctypeAsync(); - var setupData = await CreateVariantContentAsync( - setupInfo.LangEn, - setupInfo.LangDa, - setupInfo.LangBe, - setupInfo.contentType); - - var scheduleAttempt = await ContentPublishingService.PublishAsync( - setupData.Key, - new List - { - new() - { - Culture = setupInfo.LangEn.IsoCode, - }, - new() - { - Culture = setupInfo.LangDa.IsoCode, - Schedule = new ContentScheduleModel { PublishDate = _schedulePublishDate }, - }, - }, - Constants.Security.SuperUserKey); - - Assert.IsTrue(scheduleAttempt.Success); - - var schedules = ContentService.GetContentScheduleByContentId(setupData.Id); - var content = ContentService.GetById(setupData.Key); - - Assert.Multiple(() => - { - Assert.AreEqual(1, content!.PublishedCultures.Count()); - Assert.AreEqual(1, schedules.FullSchedule.Count); - }); - } - #endregion - - #region Helper methods - - private async Task<(ILanguage LangEn, ILanguage LangDa, ILanguage LangBe, IContentType contentType)> - SetupVariantDoctypeAsync() + private async Task<(ILanguage LangEn, ILanguage LangDa, ILanguage LangBe, IContentType contentType)> SetupVariantDoctypeAsync() { var langEn = (await LanguageService.GetAsync("en-US"))!; var langDa = new LanguageBuilder() @@ -1397,10 +59,10 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent .WithName("Variant Content") .WithContentVariation(ContentVariation.Culture) .AddPropertyGroup() - .WithAlias("content") - .WithName("Content") - .WithSupportsPublishing(true) - .Done() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .Done() .Build(); contentType.AllowedAsRoot = true; @@ -1410,11 +72,17 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent throw new Exception("Something unexpected went wrong setting up the test data structure"); } + contentType.AllowedContentTypes = [new ContentTypeSort(contentType.Key, 1, contentType.Alias)]; + var updateAttempt = await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + if (updateAttempt.Success is false) + { + throw new Exception("Something unexpected went wrong setting up the test data structure"); + } + return (langEn, langDa, langBe, contentType); } - private async Task CreateVariantContentAsync(ILanguage langEn, ILanguage langDa, ILanguage langBe, - IContentType contentType) + private async Task CreateVariantContentAsync(ILanguage langEn, ILanguage langDa, ILanguage langBe, IContentType contentType, Guid? parentKey = null) { var documentKey = Guid.NewGuid(); @@ -1422,8 +90,9 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent { Key = documentKey, ContentTypeKey = contentType.Key, - Variants = new[] - { + ParentKey = parentKey, + Variants = + [ new VariantModel { Name = langEn.CultureName, @@ -1442,7 +111,7 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent Culture = langBe.IsoCode, Properties = Enumerable.Empty(), } - } + ] }; var createAttempt = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); @@ -1462,36 +131,46 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent var contentType = new ContentTypeBuilder() .WithAlias("invariantContent") .WithName("Invariant Content") + .WithAllowAsRoot(true) .AddPropertyGroup() - .WithAlias("content") - .WithName("Content") - .WithSupportsPublishing(true) - .Done() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .Done() .Build(); - contentType.AllowedAsRoot = true; var createAttempt = await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); if (createAttempt.Success is false) { throw new Exception("Something unexpected went wrong setting up the test data structure"); } + contentType.AllowedContentTypes = [new ContentTypeSort(contentType.Key, 1, contentType.Alias)]; + var updateAttempt = await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + if (updateAttempt.Success is false) + { + throw new Exception("Something unexpected went wrong setting up the test data structure"); + } + return contentType; } - private async Task CreateInvariantContentAsync(IContentType contentType) + private async Task CreateInvariantContentAsync(IContentType contentType, Guid? parentKey = null) { var documentKey = Guid.NewGuid(); var createModel = new ContentCreateModel { - Key = documentKey, ContentTypeKey = contentType.Key, InvariantName = "Test", + Key = documentKey, + ContentTypeKey = contentType.Key, + InvariantName = "Test", + ParentKey = parentKey, }; var createAttempt = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); if (createAttempt.Success is false) { - throw new Exception("Something unexpected went wrong setting up the test data"); + throw new Exception($"Something unexpected went wrong setting up the test data. Status: {createAttempt.Status}"); } return createAttempt.Result.Content!; @@ -1503,8 +182,7 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent (ILanguage LangEn, ILanguage LangDa, ILanguage LangBe, IContentType contentType) setupInfo) => await ContentPublishingService.PublishAsync( setupData.Key, - new List - { + [ new() { Culture = setupInfo.LangEn.IsoCode, @@ -1531,7 +209,7 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent PublishDate = _schedulePublishDate, UnpublishDate = _scheduleUnPublishDate, }, }, - }, + ], Constants.Security.SuperUserKey); private async Task> @@ -1539,8 +217,7 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent IContent setupData) => await ContentPublishingService.PublishAsync( setupData.Key, - new List - { + [ new() { Culture = Constants.System.InvariantCulture, @@ -1550,8 +227,6 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent PublishDate = _schedulePublishDate, UnpublishDate = _scheduleUnPublishDate, }, }, - }, + ], Constants.Security.SuperUserKey); - - #endregion } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 30521afe57..289d431f45 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -190,6 +190,24 @@ BlockListElementLevelVariationTests.cs + + ContentPublishingServiceTests.cs + + + ContentPublishingServiceTests.cs + + + ContentPublishingServiceTests.cs + + + ContentPublishingServiceTests.cs + + + ContentPublishingServiceTests.cs + + + ContentPublishingServiceTests.cs + DocumentNavigationServiceTests.cs From f6d93ef0baa7770fea3c3ddbcc2ded820e69e840 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Mon, 7 Apr 2025 11:39:51 +0200 Subject: [PATCH 28/53] fix circular icon import (#18952) --- .../icon-picker-modal/icon-picker-modal.element.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-picker-modal/icon-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-picker-modal/icon-picker-modal.element.ts index a5204fc642..ece11d0ff9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-picker-modal/icon-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-picker-modal/icon-picker-modal.element.ts @@ -4,8 +4,9 @@ import { extractUmbColorVariable, umbracoColors } from '@umbraco-cms/backoffice/ import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UMB_ICON_REGISTRY_CONTEXT, type UmbIconDefinition } from '@umbraco-cms/backoffice/icon'; import type { UUIColorSwatchesEvent } from '@umbraco-cms/backoffice/external/uui'; +import type { UmbIconDefinition } from '../types.js'; +import { UMB_ICON_REGISTRY_CONTEXT } from '../icon-registry.context-token.js'; @customElement('umb-icon-picker-modal') export class UmbIconPickerModalElement extends UmbModalBaseElement { From c0f820a05ca860a24f2e80641e4d22be2246d55b Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Mon, 7 Apr 2025 11:41:27 +0200 Subject: [PATCH 29/53] remove segment toggle for elements (#18949) --- ...nt-type-workspace-view-settings.element.ts | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/views/settings/document-type-workspace-view-settings.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/views/settings/document-type-workspace-view-settings.element.ts index 6dac23b19e..51f689e68c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/views/settings/document-type-workspace-view-settings.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/views/settings/document-type-workspace-view-settings.element.ts @@ -1,5 +1,5 @@ import { UMB_DOCUMENT_TYPE_WORKSPACE_CONTEXT } from '../../document-type-workspace.context-token.js'; -import { css, html, customElement, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, state, when, nothing } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UUIBooleanInputEvent, UUIToggleElement } from '@umbraco-cms/backoffice/external/uui'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @@ -103,23 +103,29 @@ export class UmbDocumentTypeWorkspaceViewSettingsElement extends UmbLitElement i label=${this.localize.term('contentTypeEditor_cultureVariantLabel')}>
- -
- Allow editors to segment their content. -
-
- { - this.#workspaceContext?.setVariesBySegment((e.target as UUIToggleElement).checked); - }} - label=${this.localize.term('contentTypeEditor_segmentVariantLabel')}> -
-
+ + ${this._isElement + ? nothing + : html` + +
+ Allow editors to segment their content. +
+
+ { + this.#workspaceContext?.setVariesBySegment((e.target as UUIToggleElement).checked); + }} + label=${this.localize.term('contentTypeEditor_segmentVariantLabel')}> +
+
+ `} +
Date: Mon, 7 Apr 2025 13:39:09 +0200 Subject: [PATCH 30/53] Fix modal route registration circular import (#18953) * fix modal route registration circular import * Update modal-route-registration.controller.ts --- src/Umbraco.Web.UI.Client/src/packages/core/router/index.ts | 3 +-- .../modal-registration/modal-route-registration.controller.ts | 3 ++- .../core/router/{components/router-slot => route}/index.ts | 1 + .../not-found/route-not-found.element.ts | 1 - .../router/{components/router-slot => route}/route.context.ts | 4 ++-- .../{components/router-slot => route}/route.interface.ts | 0 .../router-slot => route}/router-slot-change.event.ts | 0 .../router-slot => route}/router-slot-init.event.ts | 0 .../{components/router-slot => route}/router-slot.element.ts | 2 +- 9 files changed, 7 insertions(+), 7 deletions(-) rename src/Umbraco.Web.UI.Client/src/packages/core/router/{components/router-slot => route}/index.ts (79%) rename src/Umbraco.Web.UI.Client/src/packages/core/router/{components => route}/not-found/route-not-found.element.ts (95%) rename src/Umbraco.Web.UI.Client/src/packages/core/router/{components/router-slot => route}/route.context.ts (96%) rename src/Umbraco.Web.UI.Client/src/packages/core/router/{components/router-slot => route}/route.interface.ts (100%) rename src/Umbraco.Web.UI.Client/src/packages/core/router/{components/router-slot => route}/router-slot-change.event.ts (100%) rename src/Umbraco.Web.UI.Client/src/packages/core/router/{components/router-slot => route}/router-slot-init.event.ts (100%) rename src/Umbraco.Web.UI.Client/src/packages/core/router/{components/router-slot => route}/router-slot.element.ts (98%) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/index.ts index 73587fb136..37cfc028dd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/index.ts @@ -1,5 +1,4 @@ -export * from './components/not-found/route-not-found.element.js'; -export * from './components/router-slot/index.js'; +export * from './route/index.js'; export * from './contexts/index.js'; export * from './encode-folder-name.function.js'; export * from './modal-registration/modal-route-registration.controller.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/modal-registration/modal-route-registration.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/modal-registration/modal-route-registration.controller.ts index 81477fb309..0c516e374d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/modal-registration/modal-route-registration.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/modal-registration/modal-route-registration.controller.ts @@ -1,4 +1,3 @@ -import { UMB_ROUTE_CONTEXT, UMB_ROUTE_PATH_ADDENDUM_CONTEXT } from '../index.js'; import { encodeFolderName } from '../encode-folder-name.function.js'; import type { UmbModalRouteRegistration } from './modal-route-registration.interface.js'; import type { @@ -13,6 +12,8 @@ import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import { UmbId } from '@umbraco-cms/backoffice/id'; import type { UmbDeepPartialObject } from '@umbraco-cms/backoffice/utils'; import type { IRouterSlot, Params } from '@umbraco-cms/backoffice/external/router-slot'; +import { UMB_ROUTE_PATH_ADDENDUM_CONTEXT } from '../contexts/route-path-addendum.context-token.js'; +import { UMB_ROUTE_CONTEXT } from '../route/route.context.js'; export type UmbModalRouteBuilder = (params: { [key: string]: string | number } | null) => string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/index.ts similarity index 79% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/index.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/index.ts index ed32035358..1c30607927 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/index.ts @@ -1,3 +1,4 @@ +export * from './not-found/route-not-found.element.js'; export * from './route.context.js'; export * from './router-slot-change.event.js'; export * from './router-slot-init.event.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/not-found/route-not-found.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/not-found/route-not-found.element.ts similarity index 95% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/not-found/route-not-found.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/not-found/route-not-found.element.ts index fc9a835cc9..522228a482 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/not-found/route-not-found.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/not-found/route-not-found.element.ts @@ -5,7 +5,6 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; /** * A fallback view to be used in Workspace Views, maybe this can be upgraded at a later point. */ -// TODO: Rename and move this file to a more generic place. @customElement('umb-route-not-found') export class UmbRouteNotFoundElement extends UmbLitElement { override render() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.context.ts similarity index 96% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.context.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.context.ts index ea40a677f8..15a5a48162 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.context.ts @@ -1,5 +1,3 @@ -import { umbGenerateRoutePathBuilder } from '../../generate-route-path-builder.function.js'; -import type { UmbModalRouteRegistration } from '../../modal-registration/modal-route-registration.interface.js'; import type { UmbRoute } from './route.interface.js'; import type { IRouterSlot } from '@umbraco-cms/backoffice/external/router-slot'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; @@ -7,6 +5,8 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; import { UmbStringState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbModalRouteRegistration } from '../modal-registration/modal-route-registration.interface.js'; +import { umbGenerateRoutePathBuilder } from '../generate-route-path-builder.function.js'; const EmptyDiv = document.createElement('div'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.interface.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/route.interface.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.interface.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot-change.event.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot-change.event.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot-change.event.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot-change.event.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot-init.event.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot-init.event.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot-init.event.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot-init.event.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot.element.ts similarity index 98% rename from src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot.element.ts index 291e8cd119..fb4154e46a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/router/components/router-slot/router-slot.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot.element.ts @@ -1,5 +1,4 @@ import '@umbraco-cms/backoffice/external/router-slot'; -import { UmbRoutePathAddendumResetContext } from '../../contexts/route-path-addendum-reset.context.js'; import { UmbRouterSlotInitEvent } from './router-slot-init.event.js'; import { UmbRouterSlotChangeEvent } from './router-slot-change.event.js'; import type { UmbRoute } from './route.interface.js'; @@ -7,6 +6,7 @@ import { UmbRouteContext } from './route.context.js'; import { css, html, type PropertyValueMap, customElement, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { IRouterSlot } from '@umbraco-cms/backoffice/external/router-slot'; +import { UmbRoutePathAddendumResetContext } from '../contexts/route-path-addendum-reset.context.js'; /** * @element umb-router-slot From f926316751896d6a5cc3d5e0bb929220a1fc30c2 Mon Sep 17 00:00:00 2001 From: Jacob Welander Jensen <64834767+Welander1994@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:53:01 +0200 Subject: [PATCH 31/53] V15/fix/18595 (#18925) * fix for #18595 * updates the en.ts --- src/Umbraco.Web.UI.Client/src/assets/lang/en.ts | 1 + .../modals/query-builder/query-builder-modal.element.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) 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 9bd45b6275..7c9603f402 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -1592,6 +1592,7 @@ export default { '\n If mandatory, the child template must contain a @section definition, otherwise an error is shown.\n ', queryBuilder: 'Query builder', itemsReturned: 'items returned, in', + publishedItemsReturned: 'Currently %0% published items returned, in %1% ms', iWant: 'I want', allContent: 'all content', contentOfType: 'content of type "%0%"', diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/modals/query-builder/query-builder-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/modals/query-builder/query-builder-modal.element.ts index e1b5f53152..80786f6e76 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/modals/query-builder/query-builder-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/modals/query-builder/query-builder-modal.element.ts @@ -243,9 +243,7 @@ export default class UmbTemplateQueryBuilderModalElement extends UmbModalBaseEle
- ${this._templateQuery?.resultCount ?? 0} - items returned, in - ${this._templateQuery?.executionTime ?? 0} ms + items returned, in ${this._templateQuery?.sampleResults.map( (sample) => html`${sample.name}`, From 2723e4f77cbbc1474be8473e543322bd65beebcc Mon Sep 17 00:00:00 2001 From: Henrik Date: Mon, 7 Apr 2025 15:02:28 +0200 Subject: [PATCH 32/53] Avoid unneeded Dictionary operations (#18890) --- .../Collections/ObservableDictionary.cs | 8 ++++---- src/Umbraco.Core/Models/Content.cs | 2 +- .../Models/Navigation/NavigationNode.cs | 6 ------ .../Services/LocalizedTextService.cs | 5 +---- .../Implement/MemberTypeRepository.cs | 16 ++++------------ .../Implement/PublishStatusRepository.cs | 9 +++------ .../Helpers/OAuthOptionsHelper.cs | 9 +++++---- 7 files changed, 18 insertions(+), 37 deletions(-) diff --git a/src/Umbraco.Core/Collections/ObservableDictionary.cs b/src/Umbraco.Core/Collections/ObservableDictionary.cs index 16683b3dfc..216b384e5f 100644 --- a/src/Umbraco.Core/Collections/ObservableDictionary.cs +++ b/src/Umbraco.Core/Collections/ObservableDictionary.cs @@ -160,9 +160,9 @@ public class ObservableDictionary : ObservableCollection, if (index != Count) { - foreach (TKey k in Indecies.Keys.Where(k => Indecies[k] >= index).ToList()) + foreach (KeyValuePair largerOrEqualToIndex in Indecies.Where(kvp => kvp.Value >= index)) { - Indecies[k]++; + Indecies[largerOrEqualToIndex.Key] = largerOrEqualToIndex.Value + 1; } } @@ -185,9 +185,9 @@ public class ObservableDictionary : ObservableCollection, Indecies.Remove(key); - foreach (TKey k in Indecies.Keys.Where(k => Indecies[k] > index).ToList()) + foreach (KeyValuePair largerThanIndex in Indecies.Where(kvp => kvp.Value > index)) { - Indecies[k]--; + Indecies[largerThanIndex.Key] = largerThanIndex.Value - 1; } } diff --git a/src/Umbraco.Core/Models/Content.cs b/src/Umbraco.Core/Models/Content.cs index ce8f769cad..880c7b6bd0 100644 --- a/src/Umbraco.Core/Models/Content.cs +++ b/src/Umbraco.Core/Models/Content.cs @@ -197,7 +197,7 @@ public class Content : ContentBase, IContent /// [IgnoreDataMember] - public IEnumerable PublishedCultures => _publishInfos?.Keys ?? Enumerable.Empty(); + public IEnumerable PublishedCultures => _publishInfos?.Keys ?? []; /// public bool IsCulturePublished(string culture) diff --git a/src/Umbraco.Core/Models/Navigation/NavigationNode.cs b/src/Umbraco.Core/Models/Navigation/NavigationNode.cs index d2db0c6294..f57191b9d1 100644 --- a/src/Umbraco.Core/Models/Navigation/NavigationNode.cs +++ b/src/Umbraco.Core/Models/Navigation/NavigationNode.cs @@ -39,9 +39,6 @@ public sealed class NavigationNode child.SortOrder = _children.Count; _children.Add(childKey); - - // Update the navigation structure - navigationStructure[childKey] = child; } public void RemoveChild(ConcurrentDictionary navigationStructure, Guid childKey) @@ -53,8 +50,5 @@ public sealed class NavigationNode _children.Remove(childKey); child.Parent = null; - - // Update the navigation structure - navigationStructure[childKey] = child; } } diff --git a/src/Umbraco.Core/Services/LocalizedTextService.cs b/src/Umbraco.Core/Services/LocalizedTextService.cs index df4f7b3940..343224a6e9 100644 --- a/src/Umbraco.Core/Services/LocalizedTextService.cs +++ b/src/Umbraco.Core/Services/LocalizedTextService.cs @@ -361,10 +361,7 @@ public class LocalizedTextService : ILocalizedTextService result.TryAdd(dictionaryKey, key.Value); } - if (!overallResult.ContainsKey(areaAlias)) - { - overallResult.Add(areaAlias, result); - } + overallResult.TryAdd(areaAlias, result); } // Merge English Dictionary diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberTypeRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberTypeRepository.cs index d4790a387a..61127b766b 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberTypeRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberTypeRepository.cs @@ -228,19 +228,11 @@ internal class MemberTypeRepository : ContentTypeRepositoryBase, IM ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper); foreach (IPropertyType propertyType in memberType.PropertyTypes) { - if (builtinProperties.ContainsKey(propertyType.Alias)) + // this reset's its current data type reference which will be re-assigned based on the property editor assigned on the next line + if (builtinProperties.TryGetValue(propertyType.Alias, out PropertyType? propDefinition)) { - // this reset's its current data type reference which will be re-assigned based on the property editor assigned on the next line - if (builtinProperties.TryGetValue(propertyType.Alias, out PropertyType? propDefinition)) - { - propertyType.DataTypeId = propDefinition.DataTypeId; - propertyType.DataTypeKey = propDefinition.DataTypeKey; - } - else - { - propertyType.DataTypeId = 0; - propertyType.DataTypeKey = default; - } + propertyType.DataTypeId = propDefinition.DataTypeId; + propertyType.DataTypeKey = propDefinition.DataTypeKey; } } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs index 126a62674a..366c5c6a26 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/PublishStatusRepository.cs @@ -1,6 +1,4 @@ using NPoco; -using Umbraco.Cms.Core.DeliveryApi; -using Umbraco.Cms.Core.Media.EmbedProviders; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Dtos; @@ -67,7 +65,7 @@ public class PublishStatusRepository: IPublishStatusRepository List? databaseRecords = await Database.FetchAsync(sql); IDictionary> result = Map(databaseRecords); - return result.ContainsKey(documentKey) ? result[documentKey] : new HashSet(); + return result.TryGetValue(documentKey, out ISet? value) ? value : new HashSet(); } public async Task>> GetDescendantsOrSelfPublishStatusAsync(Guid rootDocumentKey, CancellationToken cancellationToken) @@ -98,7 +96,7 @@ public class PublishStatusRepository: IPublishStatusRepository x=> (ISet) x.Where(x=> IsPublished(x)).Select(y=>y.IsoCode).ToHashSet()); } - private bool IsPublished(PublishStatusDto publishStatusDto) + private static bool IsPublished(PublishStatusDto publishStatusDto) { switch ((ContentVariation)publishStatusDto.ContentTypeVariation) { @@ -112,7 +110,7 @@ public class PublishStatusRepository: IPublishStatusRepository } } - private class PublishStatusDto + private sealed class PublishStatusDto { public const string DocumentVariantPublishStatusColumnName = "variantPublished"; @@ -133,5 +131,4 @@ public class PublishStatusRepository: IPublishStatusRepository [Column(DocumentVariantPublishStatusColumnName)] public bool DocumentVariantPublishStatus { get; set; } } - } diff --git a/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs b/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs index 255187babc..515d3b18d8 100644 --- a/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs +++ b/src/Umbraco.Web.Common/Helpers/OAuthOptionsHelper.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Extensions; @@ -14,7 +15,7 @@ public class OAuthOptionsHelper { // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 // we omit "state" and "error_uri" here as it hold no value in determining the message to display to the user - private static readonly IReadOnlyCollection _oathCallbackErrorParams = new string[] { "error", "error_description" }; + private static readonly string[] _oathCallbackErrorParams = ["error", "error_description"]; private readonly IOptions _securitySettings; @@ -43,7 +44,7 @@ public class OAuthOptionsHelper SetUmbracoRedirectWithFilteredParams(context, providerFriendlyName, eventName) .HandleResponse(); - return Task.FromResult(0); + return Task.CompletedTask; } /// @@ -60,9 +61,9 @@ public class OAuthOptionsHelper foreach (var oathCallbackErrorParam in _oathCallbackErrorParams) { - if (context.Request.Query.ContainsKey(oathCallbackErrorParam)) + if (context.Request.Query.TryGetValue(oathCallbackErrorParam, out StringValues paramValue)) { - callbackPath = callbackPath.AppendQueryStringToUrl($"{oathCallbackErrorParam}={context.Request.Query[oathCallbackErrorParam]}"); + callbackPath = callbackPath.AppendQueryStringToUrl($"{oathCallbackErrorParam}={paramValue}"); } } From db43799d0f8f9c7e93a5f6017b527bfa04392e55 Mon Sep 17 00:00:00 2001 From: Henrik Gedionsen Date: Mon, 31 Mar 2025 15:51:58 +0200 Subject: [PATCH 33/53] Avoid some heap allocations --- ...henticationDeliveryApiSwaggerGenOptions.cs | 2 +- ...SecurityRequirementsOperationFilterBase.cs | 2 +- src/Umbraco.Core/Composing/TypeFinder.cs | 5 +--- .../Exceptions/InvalidCompositionException.cs | 2 +- src/Umbraco.Core/Extensions/IntExtensions.cs | 4 +-- .../Extensions/StringExtensions.cs | 30 ++++++++----------- src/Umbraco.Core/GuidUtils.cs | 2 +- src/Umbraco.Core/HexEncoder.cs | 10 ++++--- .../Strings/DefaultShortStringHelper.cs | 7 +++-- .../Strings/Utf8ToAsciiConverter.cs | 7 ++--- 10 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs index 1c821f9681..5eaa75002c 100644 --- a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs +++ b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs @@ -48,7 +48,7 @@ public class ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions : Id = AuthSchemeName, } }, - new string[] { } + [] } } }; diff --git a/src/Umbraco.Cms.Api.Management/OpenApi/BackOfficeSecurityRequirementsOperationFilterBase.cs b/src/Umbraco.Cms.Api.Management/OpenApi/BackOfficeSecurityRequirementsOperationFilterBase.cs index 907b91cdac..e2ff1e609a 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi/BackOfficeSecurityRequirementsOperationFilterBase.cs +++ b/src/Umbraco.Cms.Api.Management/OpenApi/BackOfficeSecurityRequirementsOperationFilterBase.cs @@ -38,7 +38,7 @@ public abstract class BackOfficeSecurityRequirementsOperationFilterBase : IOpera Type = ReferenceType.SecurityScheme, Id = ManagementApiConfiguration.ApiSecurityName } - }, new string[] { } + }, [] } } }; diff --git a/src/Umbraco.Core/Composing/TypeFinder.cs b/src/Umbraco.Core/Composing/TypeFinder.cs index 6728b2c7a6..2d715ba01b 100644 --- a/src/Umbraco.Core/Composing/TypeFinder.cs +++ b/src/Umbraco.Core/Composing/TypeFinder.cs @@ -233,10 +233,7 @@ public class TypeFinder : ITypeFinder excludeFromResults = new HashSet(); } - if (exclusionFilter == null) - { - exclusionFilter = new string[] { }; - } + exclusionFilter ??= []; return GetAllAssemblies() .Where(x => excludeFromResults.Contains(x) == false diff --git a/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs b/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs index 9bc51d7b6e..0e67061902 100644 --- a/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs +++ b/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs @@ -35,7 +35,7 @@ public class InvalidCompositionException : Exception /// The added composition alias. /// The property type aliases. public InvalidCompositionException(string contentTypeAlias, string? addedCompositionAlias, string[] propertyTypeAliases) - : this(contentTypeAlias, addedCompositionAlias, propertyTypeAliases, new string[0]) + : this(contentTypeAlias, addedCompositionAlias, propertyTypeAliases, []) { } diff --git a/src/Umbraco.Core/Extensions/IntExtensions.cs b/src/Umbraco.Core/Extensions/IntExtensions.cs index d347993dd0..6bdb3c6435 100644 --- a/src/Umbraco.Core/Extensions/IntExtensions.cs +++ b/src/Umbraco.Core/Extensions/IntExtensions.cs @@ -27,8 +27,8 @@ public static class IntExtensions /// public static Guid ToGuid(this int value) { - var bytes = new byte[16]; - BitConverter.GetBytes(value).CopyTo(bytes, 0); + Span bytes = stackalloc byte[16]; + BitConverter.GetBytes(value).CopyTo(bytes); return new Guid(bytes); } } diff --git a/src/Umbraco.Core/Extensions/StringExtensions.cs b/src/Umbraco.Core/Extensions/StringExtensions.cs index 3d1ea4f83e..b7f066330c 100644 --- a/src/Umbraco.Core/Extensions/StringExtensions.cs +++ b/src/Umbraco.Core/Extensions/StringExtensions.cs @@ -152,14 +152,16 @@ public static class StringExtensions public static string ReplaceNonAlphanumericChars(this string input, char replacement) { - var inputArray = input.ToCharArray(); - var outputArray = new char[input.Length]; - for (var i = 0; i < inputArray.Length; i++) + var chars = input.ToCharArray(); + for (var i = 0; i < chars.Length; i++) { - outputArray[i] = char.IsLetterOrDigit(inputArray[i]) ? inputArray[i] : replacement; + if (!char.IsLetterOrDigit(chars[i])) + { + chars[i] = replacement; + } } - return new string(outputArray); + return new string(chars); } /// @@ -209,7 +211,7 @@ public static class StringExtensions var nonEmpty = queryStrings.Where(x => !x.IsNullOrWhiteSpace()).ToArray(); - if (url.Contains("?")) + if (url.Contains('?')) { return url + string.Join("&", nonEmpty).EnsureStartsWith('&'); } @@ -692,7 +694,7 @@ public static class StringExtensions if (input.Length == 0) { - return Array.Empty(); + return []; } // calc array size - must be groups of 4 @@ -807,7 +809,7 @@ public static class StringExtensions } // replace chars that would cause problems in URLs - var chArray = new char[pos]; + Span chArray = pos <= 1024 ? stackalloc char[pos] : new char[pos]; for (var i = 0; i < pos; i++) { var ch = str[i]; @@ -1293,8 +1295,7 @@ public static class StringExtensions } // most bytes from the hash are copied straight to the bytes of the new GUID (steps 5-7, 9, 11-12) - var newGuid = new byte[16]; - Array.Copy(hash, 0, newGuid, 0, 16); + Span newGuid = hash.AsSpan()[..16]; // set the four most significant bits (bits 12 through 15) of the time_hi_and_version field to the appropriate 4-bit version number from Section 4.1.3 (step 8) newGuid[6] = (byte)((newGuid[6] & 0x0F) | (version << 4)); @@ -1308,7 +1309,7 @@ public static class StringExtensions } // Converts a GUID (expressed as a byte array) to/from network order (MSB-first). - internal static void SwapByteOrder(byte[] guid) + internal static void SwapByteOrder(Span guid) { SwapBytes(guid, 0, 3); SwapBytes(guid, 1, 2); @@ -1316,12 +1317,7 @@ public static class StringExtensions SwapBytes(guid, 6, 7); } - private static void SwapBytes(byte[] guid, int left, int right) - { - var temp = guid[left]; - guid[left] = guid[right]; - guid[right] = temp; - } + private static void SwapBytes(Span guid, int left, int right) => (guid[left], guid[right]) = (guid[right], guid[left]); /// /// Checks if a given path is a full path including drive letter diff --git a/src/Umbraco.Core/GuidUtils.cs b/src/Umbraco.Core/GuidUtils.cs index 290f36cdcf..a549283845 100644 --- a/src/Umbraco.Core/GuidUtils.cs +++ b/src/Umbraco.Core/GuidUtils.cs @@ -63,7 +63,7 @@ public static class GuidUtils // a Guid is 3 blocks + 8 bits // so it turns into a 3*8+2 = 26 chars string - var chars = new char[length]; + Span chars = stackalloc char[length]; var i = 0; var j = 0; diff --git a/src/Umbraco.Core/HexEncoder.cs b/src/Umbraco.Core/HexEncoder.cs index b95376646b..b950f42c29 100644 --- a/src/Umbraco.Core/HexEncoder.cs +++ b/src/Umbraco.Core/HexEncoder.cs @@ -28,7 +28,8 @@ public static class HexEncoder public static string Encode(byte[] bytes) { var length = bytes.Length; - var chars = new char[length * 2]; + int charsLength = length * 2; + Span chars = charsLength <= 1024 ? stackalloc char[charsLength] : new char[charsLength]; var index = 0; for (var i = 0; i < length; i++) @@ -38,7 +39,7 @@ public static class HexEncoder chars[index++] = HexLutLo[byteIndex]; } - return new string(chars, 0, chars.Length); + return new string(chars); } /// @@ -54,7 +55,8 @@ public static class HexEncoder public static string Encode(byte[] bytes, char separator, int blockSize, int blockCount) { var length = bytes.Length; - var chars = new char[(length * 2) + blockCount]; + int charsLength = (length * 2) + blockCount; + Span chars = charsLength <= 1024 ? stackalloc char[charsLength] : new char[charsLength]; var count = 0; var size = 0; var index = 0; @@ -80,6 +82,6 @@ public static class HexEncoder count++; } - return new string(chars, 0, chars.Length); + return new string(chars); } } diff --git a/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs b/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs index b46c1405e3..581cd168a3 100644 --- a/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs +++ b/src/Umbraco.Core/Strings/DefaultShortStringHelper.cs @@ -306,7 +306,7 @@ namespace Umbraco.Cms.Core.Strings return text; } - private string RemoveSurrogatePairs(string text) + private static string RemoveSurrogatePairs(string text) { var input = text.AsSpan(); Span output = input.Length <= 1024 ? stackalloc char[input.Length] : new char[text.Length]; @@ -622,7 +622,8 @@ namespace Umbraco.Cms.Core.Strings } var input = text.ToCharArray(); - var output = new char[input.Length * 2]; + int outputLength = input.Length * 2; + Span output = outputLength <= 1024 ? stackalloc char[outputLength] : new char[outputLength]; var opos = 0; var a = input.Length > 0 ? input[0] : char.MinValue; var upos = char.IsUpper(a) ? 1 : 0; @@ -666,7 +667,7 @@ namespace Umbraco.Cms.Core.Strings output[opos++] = a; } - return new string(output, 0, opos); + return new string(output[..opos]); } #endregion diff --git a/src/Umbraco.Core/Strings/Utf8ToAsciiConverter.cs b/src/Umbraco.Core/Strings/Utf8ToAsciiConverter.cs index 74bc2fa9e8..c4bdcba2d9 100644 --- a/src/Umbraco.Core/Strings/Utf8ToAsciiConverter.cs +++ b/src/Umbraco.Core/Strings/Utf8ToAsciiConverter.cs @@ -50,11 +50,10 @@ public static class Utf8ToAsciiConverter // this is faster although it uses more memory // but... we should be filtering short strings only... - var output = new char[input.Length * 3]; // *3 because of things such as OE + int outputLength = input.Length * 3; // *3 because of things such as OE + Span output = outputLength <= 1024 ? stackalloc char[outputLength] : new char[outputLength]; var len = ToAscii(input, output, fail); - var array = new char[len]; - Array.Copy(output, array, len); - return array; + return output[..len].ToArray(); // var temp = new StringBuilder(input.Length + 16); // default is 16, start with at least input length + little extra // ToAscii(input, temp); From 4239f0fc06796895452791cd397e49a202b853d4 Mon Sep 17 00:00:00 2001 From: Henrik Gedionsen Date: Tue, 1 Apr 2025 10:14:40 +0200 Subject: [PATCH 34/53] Remove unneeded double seek --- src/Umbraco.Core/Media/TypeDetector/RasterizedTypeDetector.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Umbraco.Core/Media/TypeDetector/RasterizedTypeDetector.cs b/src/Umbraco.Core/Media/TypeDetector/RasterizedTypeDetector.cs index 6f4e7a8a86..c6793b7904 100644 --- a/src/Umbraco.Core/Media/TypeDetector/RasterizedTypeDetector.cs +++ b/src/Umbraco.Core/Media/TypeDetector/RasterizedTypeDetector.cs @@ -4,7 +4,6 @@ public abstract class RasterizedTypeDetector { public static byte[]? GetFileHeader(Stream fileStream) { - fileStream.Seek(0, SeekOrigin.Begin); var header = new byte[8]; fileStream.Seek(0, SeekOrigin.Begin); From 822c8374ef48c2c4a02540cbf1f7cc991bbec1dc Mon Sep 17 00:00:00 2001 From: Henrik Gedionsen Date: Mon, 31 Mar 2025 14:12:54 +0200 Subject: [PATCH 35/53] Avoid allocating new empty arrays, reuse existing empty array --- .../Services/ApiMediaQueryService.cs | 2 +- src/Umbraco.Core/Models/Membership/User.cs | 8 ++++---- .../Repositories/Implement/RelationRepository.cs | 4 ++-- .../Repositories/Implement/RelationTypeRepository.cs | 8 ++++---- .../Security/BackOfficeIdentityUser.cs | 2 +- .../Security/BackOfficeUserStore.cs | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs index 0eeeb4d6da..15ecaa1423 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs @@ -185,7 +185,7 @@ internal sealed class ApiMediaQueryService : IApiMediaQueryService } - private Attempt, ApiMediaQueryOperationStatus> PagedResult(IEnumerable children, int skip, int take) + private static Attempt, ApiMediaQueryOperationStatus> PagedResult(IEnumerable children, int skip, int take) { IPublishedContent[] childrenAsArray = children as IPublishedContent[] ?? children.ToArray(); var result = new PagedModel diff --git a/src/Umbraco.Core/Models/Membership/User.cs b/src/Umbraco.Core/Models/Membership/User.cs index b51d207aa3..487f9c4209 100644 --- a/src/Umbraco.Core/Models/Membership/User.cs +++ b/src/Umbraco.Core/Models/Membership/User.cs @@ -53,8 +53,8 @@ public class User : EntityBase, IUser, IProfile _language = globalSettings.DefaultUILanguage; _isApproved = true; _isLockedOut = false; - _startContentIds = new int[] { }; - _startMediaIds = new int[] { }; + _startContentIds = []; + _startMediaIds = []; // cannot be null _rawPasswordValue = string.Empty; @@ -101,8 +101,8 @@ public class User : EntityBase, IUser, IProfile _userGroups = new HashSet(); _isApproved = true; _isLockedOut = false; - _startContentIds = new int[] { }; - _startMediaIds = new int[] { }; + _startContentIds = []; + _startMediaIds = []; } /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs index a38bf4547f..ec06c5f86e 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationRepository.cs @@ -34,10 +34,10 @@ internal class RelationRepository : EntityRepositoryBase, IRelat } public IEnumerable GetPagedParentEntitiesByChildId(int childId, long pageIndex, int pageSize, out long totalRecords, params Guid[] entityTypes) - => GetPagedParentEntitiesByChildId(childId, pageIndex, pageSize, out totalRecords, new int[0], entityTypes); + => GetPagedParentEntitiesByChildId(childId, pageIndex, pageSize, out totalRecords, [], entityTypes); public IEnumerable GetPagedChildEntitiesByParentId(int parentId, long pageIndex, int pageSize, out long totalRecords, params Guid[] entityTypes) - => GetPagedChildEntitiesByParentId(parentId, pageIndex, pageSize, out totalRecords, new int[0], entityTypes); + => GetPagedChildEntitiesByParentId(parentId, pageIndex, pageSize, out totalRecords, [], entityTypes); public Task> GetPagedByChildKeyAsync(Guid childKey, int skip, int take, string? relationTypeAlias) { diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationTypeRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationTypeRepository.cs index af7458bab0..8c84f2ae65 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationTypeRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/RelationTypeRepository.cs @@ -17,7 +17,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; /// /// Represents a repository for doing CRUD operations for /// -internal class RelationTypeRepository : EntityRepositoryBase, IRelationTypeRepository +internal sealed class RelationTypeRepository : EntityRepositoryBase, IRelationTypeRepository { public RelationTypeRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger) : base(scopeAccessor, cache, logger) @@ -27,7 +27,7 @@ internal class RelationTypeRepository : EntityRepositoryBase protected override IRepositoryCachePolicy CreateCachePolicy() => new FullDataSetRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, GetEntityId, /*expires:*/ true); - private void CheckNullObjectTypeValues(IRelationType entity) + private static void CheckNullObjectTypeValues(IRelationType entity) { if (entity.ParentObjectType.HasValue && entity.ParentObjectType == Guid.Empty) { @@ -66,12 +66,12 @@ internal class RelationTypeRepository : EntityRepositoryBase public IEnumerable GetMany(params Guid[]? ids) { // should not happen due to the cache policy - if (ids?.Any() ?? false) + if (ids is { Length: not 0 }) { throw new NotImplementedException(); } - return GetMany(new int[0]); + return GetMany(Array.Empty()); } protected override IEnumerable PerformGetByQuery(IQuery query) diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs index 634641723b..c08f125955 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs @@ -63,7 +63,7 @@ public class BackOfficeIdentityUser : UmbracoIdentityUser get => _startContentIds; set { - value ??= new int[0]; + value ??= []; BeingDirty.SetPropertyValueAndDetectChanges(value, ref _startContentIds!, nameof(StartContentIds), _startIdsComparer); } diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 588aed7f63..e86d9dfc68 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -138,8 +138,8 @@ public class BackOfficeUserStore : var userEntity = new User(_globalSettings, user.Name, user.Email, user.UserName, emptyPasswordValue) { Language = user.Culture ?? _globalSettings.DefaultUILanguage, - StartContentIds = user.StartContentIds ?? new int[] { }, - StartMediaIds = user.StartMediaIds ?? new int[] { }, + StartContentIds = user.StartContentIds ?? [], + StartMediaIds = user.StartMediaIds ?? [], IsLockedOut = user.IsLockedOut, Key = user.Key, Kind = user.Kind From d9a31d337a92cdd90128127430047c5e38df875e Mon Sep 17 00:00:00 2001 From: Henrik Date: Mon, 7 Apr 2025 16:56:59 +0200 Subject: [PATCH 36/53] Avoid allocating strings for parsing comma separated int values (#18199) --- .../Extensions/StringExtensions.cs | 18 +- .../Services/PublicAccessService.cs | 2 +- src/Umbraco.Core/Services/UserService.cs | 2 +- .../StringExtensionsBenchmarks.cs | 257 ++++++++++++++++++ 4 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 tests/Umbraco.Tests.Benchmarks/StringExtensionsBenchmarks.cs diff --git a/src/Umbraco.Core/Extensions/StringExtensions.cs b/src/Umbraco.Core/Extensions/StringExtensions.cs index b7f066330c..eab9d3fabf 100644 --- a/src/Umbraco.Core/Extensions/StringExtensions.cs +++ b/src/Umbraco.Core/Extensions/StringExtensions.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; @@ -57,17 +58,24 @@ public static class StringExtensions /// public static int[] GetIdsFromPathReversed(this string path) { - string[] pathSegments = path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); - List nodeIds = new(pathSegments.Length); - for (int i = pathSegments.Length - 1; i >= 0; i--) + ReadOnlySpan pathSpan = path.AsSpan(); + List nodeIds = []; + foreach (Range rangeOfPathSegment in pathSpan.Split(Constants.CharArrays.Comma)) { - if (int.TryParse(pathSegments[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) + if (int.TryParse(pathSpan[rangeOfPathSegment], NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) { nodeIds.Add(pathSegment); } } - return nodeIds.ToArray(); + var result = new int[nodeIds.Count]; + var resultIndex = 0; + for (int i = nodeIds.Count - 1; i >= 0; i--) + { + result[resultIndex++] = nodeIds[i]; + } + + return result; } /// diff --git a/src/Umbraco.Core/Services/PublicAccessService.cs b/src/Umbraco.Core/Services/PublicAccessService.cs index 24bdbc78c0..6919ab8d3f 100644 --- a/src/Umbraco.Core/Services/PublicAccessService.cs +++ b/src/Umbraco.Core/Services/PublicAccessService.cs @@ -14,7 +14,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services; -internal class PublicAccessService : RepositoryService, IPublicAccessService +internal sealed class PublicAccessService : RepositoryService, IPublicAccessService { private readonly IPublicAccessRepository _publicAccessRepository; private readonly IEntityService _entityService; diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index c392011eca..02a1337007 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -2617,7 +2617,7 @@ internal partial class UserService : RepositoryService, IUserService { if (pathIds.Length == 0) { - return new EntityPermissionCollection(Enumerable.Empty()); + return new EntityPermissionCollection([]); } // get permissions for all nodes in the path by group diff --git a/tests/Umbraco.Tests.Benchmarks/StringExtensionsBenchmarks.cs b/tests/Umbraco.Tests.Benchmarks/StringExtensionsBenchmarks.cs new file mode 100644 index 0000000000..4f4a73c2c6 --- /dev/null +++ b/tests/Umbraco.Tests.Benchmarks/StringExtensionsBenchmarks.cs @@ -0,0 +1,257 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using BenchmarkDotNet.Attributes; +using Umbraco.Cms.Core; + +namespace Umbraco.Tests.Benchmarks; + +[MemoryDiagnoser] +public class StringExtensionsBenchmarks +{ + private static readonly Random _seededRandom = new(60); + private const int Size = 100; + private static readonly string[] _stringsWithCommaSeparatedNumbers = new string[Size]; + + static StringExtensionsBenchmarks() + { + for (var i = 0; i < Size; i++) + { + int countOfNumbers = _seededRandom.Next(2, 10); // guess on path lengths in normal use + int[] randomIds = new int[countOfNumbers]; + for (var i1 = 0; i1 < countOfNumbers; i1++) + { + randomIds[i1] = _seededRandom.Next(-1, int.MaxValue); + } + + _stringsWithCommaSeparatedNumbers[i] = string.Join(',', randomIds); + } + } + + /// + /// Ye olden way of doing it (before 20250201 https://github.com/umbraco/Umbraco-CMS/pull/18048) + /// + /// A number so the compiler/runtime doesn't optimize it away. + [Benchmark] + public int Linq() + { + var totalNumberOfIds = 0; // a number to operate on so it is not optimized away + foreach (string? stringWithCommaSeparatedNumbers in _stringsWithCommaSeparatedNumbers) + { + totalNumberOfIds += Linq(stringWithCommaSeparatedNumbers).Length; + } + + return totalNumberOfIds; + } + + private static int[] Linq(string path) + { + int[]? nodeIds = path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries) + .Select(x => + int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var output) + ? Attempt.Succeed(output) + : Attempt.Fail()) + .Where(x => x.Success) + .Select(x => x.Result) + .Reverse() + .ToArray(); + return nodeIds; + } + + /// + /// Here we are allocating strings to the separated values, + /// BUT we know the count of numbers, so we can allocate the exact size of list we need + /// + /// A number so the compiler/runtime doesn't optimize it away. + [Benchmark] + public int SplitToHeapStrings() + { + var totalNumberOfIds = 0; // a number to operate on so it is not optimized away + foreach (string stringWithCommaSeparatedNumbers in _stringsWithCommaSeparatedNumbers) + { + totalNumberOfIds += SplitToHeapStrings(stringWithCommaSeparatedNumbers).Length; + } + + return totalNumberOfIds; + } + + private static int[] SplitToHeapStrings(string path) + { + string[] pathSegments = path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); + List nodeIds = new(pathSegments.Length); // here we know how large the resulting list should at least be + for (int i = pathSegments.Length - 1; i >= 0; i--) + { + if (int.TryParse(pathSegments[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) + { + nodeIds.Add(pathSegment); + } + } + + return nodeIds.ToArray(); // allocates a new array + } + + /// + /// Here we avoid allocating strings to the separated values, + /// BUT we do not know the count of numbers, so we might end up resizing the list we add numbers to it + /// + /// A number so the compiler/runtime doesn't optimize it away. + [Benchmark] + public int SplitToStackSpansWithoutEmptyCheckReversingListAsSpan() + { + var totalNumberOfIds = 0; // a number to operate on so it is not optimized away + foreach (string stringWithCommaSeparatedNumbers in _stringsWithCommaSeparatedNumbers) + { + totalNumberOfIds += SplitToStackSpansWithoutEmptyCheckReversingListAsSpan(stringWithCommaSeparatedNumbers).Length; + } + + return totalNumberOfIds; + } + + private static int[] SplitToStackSpansWithoutEmptyCheckReversingListAsSpan(string path) + { + ReadOnlySpan pathSpan = path.AsSpan(); + MemoryExtensions.SpanSplitEnumerator pathSegments = pathSpan.Split(Constants.CharArrays.Comma); + + // Here we do NOT know how large the resulting list should at least be + // Default empty List<> internal array capacity on add is currently 4 + // If the count of numbers are less than 4, we overallocate a little + // If the count of numbers are more than 4, the list will be resized, resulting in a copy from initial array to new double size array + // If the count of numbers are more than 8, another new array is allocated and copied to + List nodeIds = []; + foreach (Range rangeOfPathSegment in pathSegments) + { + // this is only a span of the string, a string is not allocated on the heap + ReadOnlySpan pathSegmentSpan = pathSpan[rangeOfPathSegment]; + if (int.TryParse(pathSegmentSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) + { + nodeIds.Add(pathSegment); + } + } + + Span nodeIdsSpan = CollectionsMarshal.AsSpan(nodeIds); + var result = new int[nodeIdsSpan.Length]; + var resultIndex = 0; + for (int i = nodeIdsSpan.Length - 1; i >= 0; i--) + { + result[resultIndex++] = nodeIdsSpan[i]; + } + + return result; + } + + /// + /// Here we avoid allocating strings to the separated values, + /// BUT we do not know the count of numbers, so we might end up resizing the list we add numbers to it + /// + /// A number so the compiler/runtime doesn't optimize it away. + [Benchmark] + public int SplitToStackSpansWithoutEmptyCheck() + { + var totalNumberOfIds = 0; // a number to operate on so it is not optimized away + foreach (string stringWithCommaSeparatedNumbers in _stringsWithCommaSeparatedNumbers) + { + totalNumberOfIds += SplitToStackSpansWithoutEmptyCheck(stringWithCommaSeparatedNumbers).Length; + } + + return totalNumberOfIds; + } + + private static int[] SplitToStackSpansWithoutEmptyCheck(string path) + { + ReadOnlySpan pathSpan = path.AsSpan(); + MemoryExtensions.SpanSplitEnumerator pathSegments = pathSpan.Split(Constants.CharArrays.Comma); + + // Here we do NOT know how large the resulting list should at least be + // Default empty List<> internal array capacity on add is currently 4 + // If the count of numbers are less than 4, we overallocate a little + // If the count of numbers are more than 4, the list will be resized, resulting in a copy from initial array to new double size array + // If the count of numbers are more than 8, another new array is allocated and copied to + List nodeIds = []; + foreach (Range rangeOfPathSegment in pathSegments) + { + // this is only a span of the string, a string is not allocated on the heap + ReadOnlySpan pathSegmentSpan = pathSpan[rangeOfPathSegment]; + if (int.TryParse(pathSegmentSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) + { + nodeIds.Add(pathSegment); + } + } + + var result = new int[nodeIds.Count]; + var resultIndex = 0; + for (int i = nodeIds.Count - 1; i >= 0; i--) + { + result[resultIndex++] = nodeIds[i]; + } + + return result; + } + + /// + /// Here we avoid allocating strings to the separated values, + /// BUT we do not know the count of numbers, so we might end up resizing the list we add numbers to it + /// + /// Here with an empty check, unlikely in umbraco use case. + /// A number so the compiler/runtime doesn't optimize it away. + [Benchmark] + public int SplitToStackSpansWithEmptyCheck() + { + var totalNumberOfIds = 0; // a number to operate on so it is not optimized away + foreach (string stringWithCommaSeparatedNumbers in _stringsWithCommaSeparatedNumbers) + { + totalNumberOfIds += SplitToStackSpansWithEmptyCheck(stringWithCommaSeparatedNumbers).Length; + } + + return totalNumberOfIds; + } + + private static int[] SplitToStackSpansWithEmptyCheck(string path) + { + ReadOnlySpan pathSpan = path.AsSpan(); + MemoryExtensions.SpanSplitEnumerator pathSegments = pathSpan.Split(Constants.CharArrays.Comma); + + // Here we do NOT know how large the resulting list should at least be + // Default empty List<> internal array capacity on add is currently 4 + // If the count of numbers are less than 4, we overallocate a little + // If the count of numbers are more than 4, the list will be resized, resulting in a copy from initial array to new double size array + // If the count of numbers are more than 8, another new array is allocated and copied to + List nodeIds = []; + foreach (Range rangeOfPathSegment in pathSegments) + { + // this is only a span of the string, a string is not allocated on the heap + ReadOnlySpan pathSegmentSpan = pathSpan[rangeOfPathSegment]; + if (pathSegmentSpan.IsEmpty) + { + continue; + } + + if (int.TryParse(pathSegmentSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out int pathSegment)) + { + nodeIds.Add(pathSegment); + } + } + + var result = new int[nodeIds.Count]; + var resultIndex = 0; + for (int i = nodeIds.Count - 1; i >= 0; i--) + { + result[resultIndex++] = nodeIds[i]; + } + + return result; + } + +// BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.2894) +// Intel Core i7-10750H CPU 2.60GHz, 1 CPU, 12 logical and 6 physical cores +// .NET Core SDK 3.1.426 [C:\Program Files\dotnet\sdk] +// [Host] : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2 +// +// Toolchain=InProcessEmitToolchain +// +// | Method | Mean | Error | StdDev | Gen0 | Allocated | +// |------------------------------------------------------ |---------:|---------:|---------:|-------:|----------:| +// | Linq | 46.39 us | 0.515 us | 0.430 us | 9.4604 | 58.31 KB | +// | SplitToHeapStrings | 30.28 us | 0.310 us | 0.275 us | 7.0801 | 43.55 KB | +// | SplitToStackSpansWithoutEmptyCheckReversingListAsSpan | 20.47 us | 0.290 us | 0.257 us | 2.7161 | 16.73 KB | +// | SplitToStackSpansWithoutEmptyCheck | 20.60 us | 0.315 us | 0.280 us | 2.7161 | 16.73 KB | +// | SplitToStackSpansWithEmptyCheck | 20.57 us | 0.308 us | 0.288 us | 2.7161 | 16.73 KB | +} From 9c034222224570565b94d6cb63587338d5cfd133 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Mon, 7 Apr 2025 18:24:29 +0200 Subject: [PATCH 37/53] Data type References UI: Workspace + Delete (#18914) * Updated management API endpoint and model for data type references to align with that used for documents, media etc. * Refactoring. * Update src/Umbraco.Core/Constants-ReferenceTypes.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fixed typos. * generate server models * add extension slot * register data type reference info app * add reference data mappers * Added id to tracked reference content type response. * Updated OpenApi.json. * Added missing updates. * generate new models * update models * register ref item * remove debugger * render types * register member type property type ref * register media type property type ref * Renamed model and constants from code review feedback. * register reference workspace info app kind * use kind for document references * use kind for media references * use kind for member references * use deleteWithRelation kind when deleting data types * fix manifest types * fix types * Update types.gen.ts * update code to fit new server models --------- Co-authored-by: Andy Butland Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Umbraco.Cms.Api.Management/OpenApi.json | 242 +++++++++--------- src/Umbraco.Core/Services/DataTypeService.cs | 12 +- .../src/external/backend-api/src/sdk.gen.ts | 73 +++++- .../src/external/backend-api/src/types.gen.ts | 53 +++- .../document-blueprint.data.ts | 1 + .../document-blueprint.db.ts | 2 + .../src/mocks/data/document/document.data.ts | 7 + .../src/mocks/data/document/document.db.ts | 3 + .../src/mocks/data/tracked-reference.data.ts | 12 +- .../info-app/workspace-info-app.extension.ts | 4 +- .../data-type/entity-actions/manifests.ts | 4 +- .../src/packages/data-type/manifests.ts | 2 + .../data-type/reference/info-app/manifests.ts | 21 ++ .../packages/data-type/reference/manifests.ts | 3 +- .../data-type-reference.repository.ts | 35 +-- .../data-type-reference.server.data.ts | 81 ++++-- ...e-workspace-view-info-reference.element.ts | 124 --------- .../workspace-view-data-type-info.element.ts | 5 +- .../documents/document-types/constants.ts | 5 +- .../documents/document-types/manifests.ts | 2 + .../document-types/property-type/constants.ts | 1 + ...ent-type-property-type-item-ref.element.ts | 80 ++++++ ...ference-response.management-api.mapping.ts | 28 ++ .../document-types/property-type/entity.ts | 3 + .../document-types/property-type/manifests.ts | 20 ++ .../document-types/property-type/types.ts | 12 + .../documents/reference/info-app/manifests.ts | 7 +- ...ference-response.management-api.mapping.ts | 7 +- .../documents/documents/reference/types.ts | 8 +- .../packages/media/media-types/constants.ts | 1 + .../packages/media/media-types/manifests.ts | 10 +- .../media-types/property-type/constants.ts | 1 + .../media/media-types/property-type/entity.ts | 3 + .../media-types/property-type/manifests.ts | 20 ++ ...dia-type-property-type-item-ref.element.ts | 80 ++++++ ...ference-response.management-api.mapping.ts | 28 ++ .../media/media-types/property-type/types.ts | 12 + .../media/reference/info-app/manifests.ts | 7 +- ...a-references-workspace-info-app.element.ts | 161 ------------ ...ference-response.management-api.mapping.ts | 7 +- .../media/media/reference/repository/types.ts | 8 +- .../packages/members/member-type/constants.ts | 3 +- .../packages/members/member-type/manifests.ts | 2 + .../member-type/property-type/constants.ts | 1 + .../member-type/property-type/entity.ts | 3 + .../member-type/property-type/manifests.ts | 20 ++ ...ber-type-property-type-item-ref.element.ts | 80 ++++++ ...ference-response.management-api.mapping.ts | 28 ++ .../member-type/property-type/types.ts | 12 + .../member/reference/info-app/manifests.ts | 7 +- ...r-references-workspace-info-app.element.ts | 161 ------------ ...ference-response.management-api.mapping.ts | 7 +- .../member/reference/repository/types.ts | 8 +- .../packages/relations/relations/manifests.ts | 2 + ...references-workspace-view-info.element.ts} | 84 +++--- ...ity-references-workspace-view-info.kind.ts | 14 + .../reference/workspace-info-app/manifests.ts | 5 + .../reference/workspace-info-app/types.ts | 17 ++ 58 files changed, 960 insertions(+), 689 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/data-type/reference/info-app/manifests.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/data-type-workspace-view-info-reference.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-item-ref.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-reference-response.management-api.mapping.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/entity.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/types.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/entity.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-item-ref.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-reference-response.management-api.mapping.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/types.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/media-references-workspace-info-app.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/entity.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-item-ref.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-reference-response.management-api.mapping.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/types.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/member-references-workspace-info-app.element.ts rename src/Umbraco.Web.UI.Client/src/packages/{documents/documents/reference/info-app/document-references-workspace-view-info.element.ts => relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts} (52%) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.kind.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/types.ts diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 304e344e62..ecabf0cdb8 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -37996,45 +37996,6 @@ }, "additionalProperties": false }, - "DocumentTypePropertyReferenceResponseModel": { - "required": [ - "$type", - "documentType", - "id" - ], - "type": "object", - "properties": { - "$type": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string", - "nullable": true - }, - "alias": { - "type": "string", - "nullable": true - }, - "documentType": { - "oneOf": [ - { - "$ref": "#/components/schemas/TrackedReferenceDocumentTypeModel" - } - ] - } - }, - "additionalProperties": false, - "discriminator": { - "propertyName": "$type", - "mapping": { - "DocumentTypePropertyReferenceResponseModel": "#/components/schemas/DocumentTypePropertyReferenceResponseModel" - } - } - }, "DocumentTypePropertyTypeContainerResponseModel": { "required": [ "id", @@ -38070,6 +38031,45 @@ }, "additionalProperties": false }, + "DocumentTypePropertyTypeReferenceResponseModel": { + "required": [ + "$type", + "documentType", + "id" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "nullable": true + }, + "alias": { + "type": "string", + "nullable": true + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrackedReferenceDocumentTypeModel" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTypePropertyTypeReferenceResponseModel": "#/components/schemas/DocumentTypePropertyTypeReferenceResponseModel" + } + } + }, "DocumentTypePropertyTypeResponseModel": { "required": [ "alias", @@ -40099,45 +40099,6 @@ }, "additionalProperties": false }, - "MediaTypePropertyReferenceResponseModel": { - "required": [ - "$type", - "id", - "mediaType" - ], - "type": "object", - "properties": { - "$type": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string", - "nullable": true - }, - "alias": { - "type": "string", - "nullable": true - }, - "mediaType": { - "oneOf": [ - { - "$ref": "#/components/schemas/TrackedReferenceMediaTypeModel" - } - ] - } - }, - "additionalProperties": false, - "discriminator": { - "propertyName": "$type", - "mapping": { - "MediaTypePropertyReferenceResponseModel": "#/components/schemas/MediaTypePropertyReferenceResponseModel" - } - } - }, "MediaTypePropertyTypeContainerResponseModel": { "required": [ "id", @@ -40173,6 +40134,45 @@ }, "additionalProperties": false }, + "MediaTypePropertyTypeReferenceResponseModel": { + "required": [ + "$type", + "id", + "mediaType" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "nullable": true + }, + "alias": { + "type": "string", + "nullable": true + }, + "mediaType": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrackedReferenceMediaTypeModel" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "MediaTypePropertyTypeReferenceResponseModel": "#/components/schemas/MediaTypePropertyTypeReferenceResponseModel" + } + } + }, "MediaTypePropertyTypeResponseModel": { "required": [ "alias", @@ -40916,45 +40916,6 @@ }, "additionalProperties": false }, - "MemberTypePropertyReferenceResponseModel": { - "required": [ - "$type", - "id", - "memberType" - ], - "type": "object", - "properties": { - "$type": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string", - "nullable": true - }, - "alias": { - "type": "string", - "nullable": true - }, - "memberType": { - "oneOf": [ - { - "$ref": "#/components/schemas/TrackedReferenceMemberTypeModel" - } - ] - } - }, - "additionalProperties": false, - "discriminator": { - "propertyName": "$type", - "mapping": { - "MemberTypePropertyReferenceResponseModel": "#/components/schemas/MemberTypePropertyReferenceResponseModel" - } - } - }, "MemberTypePropertyTypeContainerResponseModel": { "required": [ "id", @@ -40990,6 +40951,45 @@ }, "additionalProperties": false }, + "MemberTypePropertyTypeReferenceResponseModel": { + "required": [ + "$type", + "id", + "memberType" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "nullable": true + }, + "alias": { + "type": "string", + "nullable": true + }, + "memberType": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrackedReferenceMemberTypeModel" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "MemberTypePropertyTypeReferenceResponseModel": "#/components/schemas/MemberTypePropertyTypeReferenceResponseModel" + } + } + }, "MemberTypePropertyTypeResponseModel": { "required": [ "alias", @@ -42163,19 +42163,19 @@ "$ref": "#/components/schemas/DocumentReferenceResponseModel" }, { - "$ref": "#/components/schemas/DocumentTypePropertyReferenceResponseModel" + "$ref": "#/components/schemas/DocumentTypePropertyTypeReferenceResponseModel" }, { "$ref": "#/components/schemas/MediaReferenceResponseModel" }, { - "$ref": "#/components/schemas/MediaTypePropertyReferenceResponseModel" + "$ref": "#/components/schemas/MediaTypePropertyTypeReferenceResponseModel" }, { "$ref": "#/components/schemas/MemberReferenceResponseModel" }, { - "$ref": "#/components/schemas/MemberTypePropertyReferenceResponseModel" + "$ref": "#/components/schemas/MemberTypePropertyTypeReferenceResponseModel" } ] } @@ -47248,4 +47248,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/Umbraco.Core/Services/DataTypeService.cs b/src/Umbraco.Core/Services/DataTypeService.cs index 7bc0b4d95f..0c58eae415 100644 --- a/src/Umbraco.Core/Services/DataTypeService.cs +++ b/src/Umbraco.Core/Services/DataTypeService.cs @@ -94,7 +94,7 @@ namespace Umbraco.Cms.Core.Services.Implement public Attempt?> CreateContainer(int parentId, Guid key, string name, int userId = Constants.Security.SuperUserId) { EventMessages evtMsgs = EventMessagesFactory.Get(); - using (ScopeProvider.CreateCoreScope(autoComplete:true)) + using (ScopeProvider.CreateCoreScope(autoComplete: true)) { try { @@ -161,7 +161,7 @@ namespace Umbraco.Cms.Core.Services.Implement public Attempt SaveContainer(EntityContainer container, int userId = Constants.Security.SuperUserId) { EventMessages evtMsgs = EventMessagesFactory.Get(); - using (ScopeProvider.CreateCoreScope(autoComplete:true)) + using (ScopeProvider.CreateCoreScope(autoComplete: true)) { var isNew = container.Id == 0; Guid? parentKey = isNew && container.ParentId > 0 ? _dataTypeContainerRepository.Get(container.ParentId)?.Key : null; @@ -187,7 +187,7 @@ namespace Umbraco.Cms.Core.Services.Implement public Attempt DeleteContainer(int containerId, int userId = Constants.Security.SuperUserId) { EventMessages evtMsgs = EventMessagesFactory.Get(); - using (ScopeProvider.CreateCoreScope(autoComplete:true)) + using (ScopeProvider.CreateCoreScope(autoComplete: true)) { EntityContainer? container = _dataTypeContainerRepository.Get(containerId); if (container == null) @@ -212,7 +212,7 @@ namespace Umbraco.Cms.Core.Services.Implement public Attempt?> RenameContainer(int id, string name, int userId = Constants.Security.SuperUserId) { EventMessages evtMsgs = EventMessagesFactory.Get(); - using (ScopeProvider.CreateCoreScope(autoComplete:true)) + using (ScopeProvider.CreateCoreScope(autoComplete: true)) { try { @@ -511,7 +511,7 @@ namespace Umbraco.Cms.Core.Services.Implement DataTypeOperationStatus.Success => OperationResult.Attempt.Succeed(MoveOperationStatusType.Success, evtMsgs, result.Result), DataTypeOperationStatus.CancelledByNotification => OperationResult.Attempt.Fail(MoveOperationStatusType.FailedCancelledByEvent, evtMsgs, result.Result), DataTypeOperationStatus.ParentNotFound => OperationResult.Attempt.Fail(MoveOperationStatusType.FailedParentNotFound, evtMsgs, result.Result), - _ => OperationResult.Attempt.Fail(MoveOperationStatusType.FailedNotAllowedByPath, evtMsgs, result.Result, new InvalidOperationException($"Invalid operation status: {result.Status}")), + _ => OperationResult.Attempt.Fail(MoveOperationStatusType.FailedNotAllowedByPath, evtMsgs, result.Result, new InvalidOperationException($"Invalid operation status: {result.Status}")), }; } @@ -724,7 +724,7 @@ namespace Umbraco.Cms.Core.Services.Implement /// public async Task>, DataTypeOperationStatus>> GetReferencesAsync(Guid id) { - using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete:true); + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); IDataType? dataType = GetDataTypeFromRepository(id); if (dataType == null) { diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts index 770d927fa6..2b732b2c55 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { GetCultureData, GetCultureResponse, PostDataTypeData, PostDataTypeResponse, GetDataTypeByIdData, GetDataTypeByIdResponse, DeleteDataTypeByIdData, DeleteDataTypeByIdResponse, PutDataTypeByIdData, PutDataTypeByIdResponse, PostDataTypeByIdCopyData, PostDataTypeByIdCopyResponse, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedResponse, PutDataTypeByIdMoveData, PutDataTypeByIdMoveResponse, GetDataTypeByIdReferencesData, GetDataTypeByIdReferencesResponse, GetDataTypeConfigurationResponse, PostDataTypeFolderData, PostDataTypeFolderResponse, GetDataTypeFolderByIdData, GetDataTypeFolderByIdResponse, DeleteDataTypeFolderByIdData, DeleteDataTypeFolderByIdResponse, PutDataTypeFolderByIdData, PutDataTypeFolderByIdResponse, GetFilterDataTypeData, GetFilterDataTypeResponse, GetItemDataTypeData, GetItemDataTypeResponse, GetItemDataTypeSearchData, GetItemDataTypeSearchResponse, GetTreeDataTypeAncestorsData, GetTreeDataTypeAncestorsResponse, GetTreeDataTypeChildrenData, GetTreeDataTypeChildrenResponse, GetTreeDataTypeRootData, GetTreeDataTypeRootResponse, GetDictionaryData, GetDictionaryResponse, PostDictionaryData, PostDictionaryResponse, GetDictionaryByIdData, GetDictionaryByIdResponse, DeleteDictionaryByIdData, DeleteDictionaryByIdResponse, PutDictionaryByIdData, PutDictionaryByIdResponse, GetDictionaryByIdExportData, GetDictionaryByIdExportResponse, PutDictionaryByIdMoveData, PutDictionaryByIdMoveResponse, PostDictionaryImportData, PostDictionaryImportResponse, GetItemDictionaryData, GetItemDictionaryResponse, GetTreeDictionaryAncestorsData, GetTreeDictionaryAncestorsResponse, GetTreeDictionaryChildrenData, GetTreeDictionaryChildrenResponse, GetTreeDictionaryRootData, GetTreeDictionaryRootResponse, GetCollectionDocumentByIdData, GetCollectionDocumentByIdResponse, PostDocumentData, PostDocumentResponse, GetDocumentByIdData, GetDocumentByIdResponse, DeleteDocumentByIdData, DeleteDocumentByIdResponse, PutDocumentByIdData, PutDocumentByIdResponse, GetDocumentByIdAuditLogData, GetDocumentByIdAuditLogResponse, PostDocumentByIdCopyData, PostDocumentByIdCopyResponse, GetDocumentByIdDomainsData, GetDocumentByIdDomainsResponse, PutDocumentByIdDomainsData, PutDocumentByIdDomainsResponse, PutDocumentByIdMoveData, PutDocumentByIdMoveResponse, PutDocumentByIdMoveToRecycleBinData, PutDocumentByIdMoveToRecycleBinResponse, GetDocumentByIdNotificationsData, GetDocumentByIdNotificationsResponse, PutDocumentByIdNotificationsData, PutDocumentByIdNotificationsResponse, PostDocumentByIdPublicAccessData, PostDocumentByIdPublicAccessResponse, DeleteDocumentByIdPublicAccessData, DeleteDocumentByIdPublicAccessResponse, GetDocumentByIdPublicAccessData, GetDocumentByIdPublicAccessResponse, PutDocumentByIdPublicAccessData, PutDocumentByIdPublicAccessResponse, PutDocumentByIdPublishData, PutDocumentByIdPublishResponse, PutDocumentByIdPublishWithDescendantsData, PutDocumentByIdPublishWithDescendantsResponse, GetDocumentByIdPublishWithDescendantsResultByTaskIdData, GetDocumentByIdPublishWithDescendantsResultByTaskIdResponse, GetDocumentByIdPublishedData, GetDocumentByIdPublishedResponse, GetDocumentByIdReferencedByData, GetDocumentByIdReferencedByResponse, GetDocumentByIdReferencedDescendantsData, GetDocumentByIdReferencedDescendantsResponse, PutDocumentByIdUnpublishData, PutDocumentByIdUnpublishResponse, PutDocumentByIdValidateData, PutDocumentByIdValidateResponse, PutUmbracoManagementApiV11DocumentByIdValidate11Data, PutUmbracoManagementApiV11DocumentByIdValidate11Response, GetDocumentAreReferencedData, GetDocumentAreReferencedResponse, GetDocumentConfigurationResponse, PutDocumentSortData, PutDocumentSortResponse, GetDocumentUrlsData, GetDocumentUrlsResponse, PostDocumentValidateData, PostDocumentValidateResponse, GetItemDocumentData, GetItemDocumentResponse, GetItemDocumentSearchData, GetItemDocumentSearchResponse, DeleteRecycleBinDocumentResponse, DeleteRecycleBinDocumentByIdData, DeleteRecycleBinDocumentByIdResponse, GetRecycleBinDocumentByIdOriginalParentData, GetRecycleBinDocumentByIdOriginalParentResponse, PutRecycleBinDocumentByIdRestoreData, PutRecycleBinDocumentByIdRestoreResponse, GetRecycleBinDocumentChildrenData, GetRecycleBinDocumentChildrenResponse, GetRecycleBinDocumentRootData, GetRecycleBinDocumentRootResponse, GetTreeDocumentAncestorsData, GetTreeDocumentAncestorsResponse, GetTreeDocumentChildrenData, GetTreeDocumentChildrenResponse, GetTreeDocumentRootData, GetTreeDocumentRootResponse, PostDocumentBlueprintData, PostDocumentBlueprintResponse, GetDocumentBlueprintByIdData, GetDocumentBlueprintByIdResponse, DeleteDocumentBlueprintByIdData, DeleteDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdData, PutDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdMoveData, PutDocumentBlueprintByIdMoveResponse, PostDocumentBlueprintFolderData, PostDocumentBlueprintFolderResponse, GetDocumentBlueprintFolderByIdData, GetDocumentBlueprintFolderByIdResponse, DeleteDocumentBlueprintFolderByIdData, DeleteDocumentBlueprintFolderByIdResponse, PutDocumentBlueprintFolderByIdData, PutDocumentBlueprintFolderByIdResponse, PostDocumentBlueprintFromDocumentData, PostDocumentBlueprintFromDocumentResponse, GetItemDocumentBlueprintData, GetItemDocumentBlueprintResponse, GetTreeDocumentBlueprintAncestorsData, GetTreeDocumentBlueprintAncestorsResponse, GetTreeDocumentBlueprintChildrenData, GetTreeDocumentBlueprintChildrenResponse, GetTreeDocumentBlueprintRootData, GetTreeDocumentBlueprintRootResponse, PostDocumentTypeData, PostDocumentTypeResponse, GetDocumentTypeByIdData, GetDocumentTypeByIdResponse, DeleteDocumentTypeByIdData, DeleteDocumentTypeByIdResponse, PutDocumentTypeByIdData, PutDocumentTypeByIdResponse, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenResponse, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintResponse, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesResponse, PostDocumentTypeByIdCopyData, PostDocumentTypeByIdCopyResponse, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportResponse, PutDocumentTypeByIdImportData, PutDocumentTypeByIdImportResponse, PutDocumentTypeByIdMoveData, PutDocumentTypeByIdMoveResponse, GetDocumentTypeAllowedAtRootData, GetDocumentTypeAllowedAtRootResponse, PostDocumentTypeAvailableCompositionsData, PostDocumentTypeAvailableCompositionsResponse, GetDocumentTypeConfigurationResponse, PostDocumentTypeFolderData, PostDocumentTypeFolderResponse, GetDocumentTypeFolderByIdData, GetDocumentTypeFolderByIdResponse, DeleteDocumentTypeFolderByIdData, DeleteDocumentTypeFolderByIdResponse, PutDocumentTypeFolderByIdData, PutDocumentTypeFolderByIdResponse, PostDocumentTypeImportData, PostDocumentTypeImportResponse, GetItemDocumentTypeData, GetItemDocumentTypeResponse, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchResponse, GetTreeDocumentTypeAncestorsData, GetTreeDocumentTypeAncestorsResponse, GetTreeDocumentTypeChildrenData, GetTreeDocumentTypeChildrenResponse, GetTreeDocumentTypeRootData, GetTreeDocumentTypeRootResponse, GetDocumentVersionData, GetDocumentVersionResponse, GetDocumentVersionByIdData, GetDocumentVersionByIdResponse, PutDocumentVersionByIdPreventCleanupData, PutDocumentVersionByIdPreventCleanupResponse, PostDocumentVersionByIdRollbackData, PostDocumentVersionByIdRollbackResponse, PostDynamicRootQueryData, PostDynamicRootQueryResponse, GetDynamicRootStepsResponse, GetHealthCheckGroupData, GetHealthCheckGroupResponse, GetHealthCheckGroupByNameData, GetHealthCheckGroupByNameResponse, PostHealthCheckGroupByNameCheckData, PostHealthCheckGroupByNameCheckResponse, PostHealthCheckExecuteActionData, PostHealthCheckExecuteActionResponse, GetHelpData, GetHelpResponse, GetImagingResizeUrlsData, GetImagingResizeUrlsResponse, GetImportAnalyzeData, GetImportAnalyzeResponse, GetIndexerData, GetIndexerResponse, GetIndexerByIndexNameData, GetIndexerByIndexNameResponse, PostIndexerByIndexNameRebuildData, PostIndexerByIndexNameRebuildResponse, GetInstallSettingsResponse, PostInstallSetupData, PostInstallSetupResponse, PostInstallValidateDatabaseData, PostInstallValidateDatabaseResponse, GetItemLanguageData, GetItemLanguageResponse, GetItemLanguageDefaultResponse, GetLanguageData, GetLanguageResponse, PostLanguageData, PostLanguageResponse, GetLanguageByIsoCodeData, GetLanguageByIsoCodeResponse, DeleteLanguageByIsoCodeData, DeleteLanguageByIsoCodeResponse, PutLanguageByIsoCodeData, PutLanguageByIsoCodeResponse, GetLogViewerLevelData, GetLogViewerLevelResponse, GetLogViewerLevelCountData, GetLogViewerLevelCountResponse, GetLogViewerLogData, GetLogViewerLogResponse, GetLogViewerMessageTemplateData, GetLogViewerMessageTemplateResponse, GetLogViewerSavedSearchData, GetLogViewerSavedSearchResponse, PostLogViewerSavedSearchData, PostLogViewerSavedSearchResponse, GetLogViewerSavedSearchByNameData, GetLogViewerSavedSearchByNameResponse, DeleteLogViewerSavedSearchByNameData, DeleteLogViewerSavedSearchByNameResponse, GetLogViewerValidateLogsSizeData, GetLogViewerValidateLogsSizeResponse, GetManifestManifestResponse, GetManifestManifestPrivateResponse, GetManifestManifestPublicResponse, GetCollectionMediaData, GetCollectionMediaResponse, GetItemMediaData, GetItemMediaResponse, GetItemMediaSearchData, GetItemMediaSearchResponse, PostMediaData, PostMediaResponse, GetMediaByIdData, GetMediaByIdResponse, DeleteMediaByIdData, DeleteMediaByIdResponse, PutMediaByIdData, PutMediaByIdResponse, GetMediaByIdAuditLogData, GetMediaByIdAuditLogResponse, PutMediaByIdMoveData, PutMediaByIdMoveResponse, PutMediaByIdMoveToRecycleBinData, PutMediaByIdMoveToRecycleBinResponse, GetMediaByIdReferencedByData, GetMediaByIdReferencedByResponse, GetMediaByIdReferencedDescendantsData, GetMediaByIdReferencedDescendantsResponse, PutMediaByIdValidateData, PutMediaByIdValidateResponse, GetMediaAreReferencedData, GetMediaAreReferencedResponse, GetMediaConfigurationResponse, PutMediaSortData, PutMediaSortResponse, GetMediaUrlsData, GetMediaUrlsResponse, PostMediaValidateData, PostMediaValidateResponse, DeleteRecycleBinMediaResponse, DeleteRecycleBinMediaByIdData, DeleteRecycleBinMediaByIdResponse, GetRecycleBinMediaByIdOriginalParentData, GetRecycleBinMediaByIdOriginalParentResponse, PutRecycleBinMediaByIdRestoreData, PutRecycleBinMediaByIdRestoreResponse, GetRecycleBinMediaChildrenData, GetRecycleBinMediaChildrenResponse, GetRecycleBinMediaRootData, GetRecycleBinMediaRootResponse, GetTreeMediaAncestorsData, GetTreeMediaAncestorsResponse, GetTreeMediaChildrenData, GetTreeMediaChildrenResponse, GetTreeMediaRootData, GetTreeMediaRootResponse, GetItemMediaTypeData, GetItemMediaTypeResponse, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedResponse, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersResponse, GetItemMediaTypeSearchData, GetItemMediaTypeSearchResponse, PostMediaTypeData, PostMediaTypeResponse, GetMediaTypeByIdData, GetMediaTypeByIdResponse, DeleteMediaTypeByIdData, DeleteMediaTypeByIdResponse, PutMediaTypeByIdData, PutMediaTypeByIdResponse, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenResponse, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesResponse, PostMediaTypeByIdCopyData, PostMediaTypeByIdCopyResponse, GetMediaTypeByIdExportData, GetMediaTypeByIdExportResponse, PutMediaTypeByIdImportData, PutMediaTypeByIdImportResponse, PutMediaTypeByIdMoveData, PutMediaTypeByIdMoveResponse, GetMediaTypeAllowedAtRootData, GetMediaTypeAllowedAtRootResponse, PostMediaTypeAvailableCompositionsData, PostMediaTypeAvailableCompositionsResponse, GetMediaTypeConfigurationResponse, PostMediaTypeFolderData, PostMediaTypeFolderResponse, GetMediaTypeFolderByIdData, GetMediaTypeFolderByIdResponse, DeleteMediaTypeFolderByIdData, DeleteMediaTypeFolderByIdResponse, PutMediaTypeFolderByIdData, PutMediaTypeFolderByIdResponse, PostMediaTypeImportData, PostMediaTypeImportResponse, GetTreeMediaTypeAncestorsData, GetTreeMediaTypeAncestorsResponse, GetTreeMediaTypeChildrenData, GetTreeMediaTypeChildrenResponse, GetTreeMediaTypeRootData, GetTreeMediaTypeRootResponse, GetFilterMemberData, GetFilterMemberResponse, GetItemMemberData, GetItemMemberResponse, GetItemMemberSearchData, GetItemMemberSearchResponse, PostMemberData, PostMemberResponse, GetMemberByIdData, GetMemberByIdResponse, DeleteMemberByIdData, DeleteMemberByIdResponse, PutMemberByIdData, PutMemberByIdResponse, GetMemberByIdReferencedByData, GetMemberByIdReferencedByResponse, GetMemberByIdReferencedDescendantsData, GetMemberByIdReferencedDescendantsResponse, PutMemberByIdValidateData, PutMemberByIdValidateResponse, GetMemberAreReferencedData, GetMemberAreReferencedResponse, GetMemberConfigurationResponse, PostMemberValidateData, PostMemberValidateResponse, GetItemMemberGroupData, GetItemMemberGroupResponse, GetMemberGroupData, GetMemberGroupResponse, PostMemberGroupData, PostMemberGroupResponse, GetMemberGroupByIdData, GetMemberGroupByIdResponse, DeleteMemberGroupByIdData, DeleteMemberGroupByIdResponse, PutMemberGroupByIdData, PutMemberGroupByIdResponse, GetTreeMemberGroupRootData, GetTreeMemberGroupRootResponse, GetItemMemberTypeData, GetItemMemberTypeResponse, GetItemMemberTypeSearchData, GetItemMemberTypeSearchResponse, PostMemberTypeData, PostMemberTypeResponse, GetMemberTypeByIdData, GetMemberTypeByIdResponse, DeleteMemberTypeByIdData, DeleteMemberTypeByIdResponse, PutMemberTypeByIdData, PutMemberTypeByIdResponse, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesResponse, PostMemberTypeByIdCopyData, PostMemberTypeByIdCopyResponse, PostMemberTypeAvailableCompositionsData, PostMemberTypeAvailableCompositionsResponse, GetMemberTypeConfigurationResponse, GetTreeMemberTypeRootData, GetTreeMemberTypeRootResponse, PostModelsBuilderBuildResponse, GetModelsBuilderDashboardResponse, GetModelsBuilderStatusResponse, GetObjectTypesData, GetObjectTypesResponse, GetOembedQueryData, GetOembedQueryResponse, PostPackageByNameRunMigrationData, PostPackageByNameRunMigrationResponse, GetPackageConfigurationResponse, GetPackageCreatedData, GetPackageCreatedResponse, PostPackageCreatedData, PostPackageCreatedResponse, GetPackageCreatedByIdData, GetPackageCreatedByIdResponse, DeletePackageCreatedByIdData, DeletePackageCreatedByIdResponse, PutPackageCreatedByIdData, PutPackageCreatedByIdResponse, GetPackageCreatedByIdDownloadData, GetPackageCreatedByIdDownloadResponse, GetPackageMigrationStatusData, GetPackageMigrationStatusResponse, GetItemPartialViewData, GetItemPartialViewResponse, PostPartialViewData, PostPartialViewResponse, GetPartialViewByPathData, GetPartialViewByPathResponse, DeletePartialViewByPathData, DeletePartialViewByPathResponse, PutPartialViewByPathData, PutPartialViewByPathResponse, PutPartialViewByPathRenameData, PutPartialViewByPathRenameResponse, PostPartialViewFolderData, PostPartialViewFolderResponse, GetPartialViewFolderByPathData, GetPartialViewFolderByPathResponse, DeletePartialViewFolderByPathData, DeletePartialViewFolderByPathResponse, GetPartialViewSnippetData, GetPartialViewSnippetResponse, GetPartialViewSnippetByIdData, GetPartialViewSnippetByIdResponse, GetTreePartialViewAncestorsData, GetTreePartialViewAncestorsResponse, GetTreePartialViewChildrenData, GetTreePartialViewChildrenResponse, GetTreePartialViewRootData, GetTreePartialViewRootResponse, DeletePreviewResponse, PostPreviewResponse, GetProfilingStatusResponse, PutProfilingStatusData, PutProfilingStatusResponse, GetPropertyTypeIsUsedData, GetPropertyTypeIsUsedResponse, PostPublishedCacheRebuildResponse, GetPublishedCacheRebuildStatusResponse, PostPublishedCacheReloadResponse, GetRedirectManagementData, GetRedirectManagementResponse, GetRedirectManagementByIdData, GetRedirectManagementByIdResponse, DeleteRedirectManagementByIdData, DeleteRedirectManagementByIdResponse, GetRedirectManagementStatusResponse, PostRedirectManagementStatusData, PostRedirectManagementStatusResponse, GetRelationByRelationTypeIdData, GetRelationByRelationTypeIdResponse, GetItemRelationTypeData, GetItemRelationTypeResponse, GetRelationTypeData, GetRelationTypeResponse, GetRelationTypeByIdData, GetRelationTypeByIdResponse, GetItemScriptData, GetItemScriptResponse, PostScriptData, PostScriptResponse, GetScriptByPathData, GetScriptByPathResponse, DeleteScriptByPathData, DeleteScriptByPathResponse, PutScriptByPathData, PutScriptByPathResponse, PutScriptByPathRenameData, PutScriptByPathRenameResponse, PostScriptFolderData, PostScriptFolderResponse, GetScriptFolderByPathData, GetScriptFolderByPathResponse, DeleteScriptFolderByPathData, DeleteScriptFolderByPathResponse, GetTreeScriptAncestorsData, GetTreeScriptAncestorsResponse, GetTreeScriptChildrenData, GetTreeScriptChildrenResponse, GetTreeScriptRootData, GetTreeScriptRootResponse, GetSearcherData, GetSearcherResponse, GetSearcherBySearcherNameQueryData, GetSearcherBySearcherNameQueryResponse, GetSecurityConfigurationResponse, PostSecurityForgotPasswordData, PostSecurityForgotPasswordResponse, PostSecurityForgotPasswordResetData, PostSecurityForgotPasswordResetResponse, PostSecurityForgotPasswordVerifyData, PostSecurityForgotPasswordVerifyResponse, GetSegmentData, GetSegmentResponse, GetServerConfigurationResponse, GetServerInformationResponse, GetServerStatusResponse, GetServerTroubleshootingResponse, GetServerUpgradeCheckResponse, GetItemStaticFileData, GetItemStaticFileResponse, GetTreeStaticFileAncestorsData, GetTreeStaticFileAncestorsResponse, GetTreeStaticFileChildrenData, GetTreeStaticFileChildrenResponse, GetTreeStaticFileRootData, GetTreeStaticFileRootResponse, GetItemStylesheetData, GetItemStylesheetResponse, PostStylesheetData, PostStylesheetResponse, GetStylesheetByPathData, GetStylesheetByPathResponse, DeleteStylesheetByPathData, DeleteStylesheetByPathResponse, PutStylesheetByPathData, PutStylesheetByPathResponse, PutStylesheetByPathRenameData, PutStylesheetByPathRenameResponse, PostStylesheetFolderData, PostStylesheetFolderResponse, GetStylesheetFolderByPathData, GetStylesheetFolderByPathResponse, DeleteStylesheetFolderByPathData, DeleteStylesheetFolderByPathResponse, GetTreeStylesheetAncestorsData, GetTreeStylesheetAncestorsResponse, GetTreeStylesheetChildrenData, GetTreeStylesheetChildrenResponse, GetTreeStylesheetRootData, GetTreeStylesheetRootResponse, GetTagData, GetTagResponse, GetTelemetryData, GetTelemetryResponse, GetTelemetryLevelResponse, PostTelemetryLevelData, PostTelemetryLevelResponse, GetItemTemplateData, GetItemTemplateResponse, GetItemTemplateSearchData, GetItemTemplateSearchResponse, PostTemplateData, PostTemplateResponse, GetTemplateByIdData, GetTemplateByIdResponse, DeleteTemplateByIdData, DeleteTemplateByIdResponse, PutTemplateByIdData, PutTemplateByIdResponse, GetTemplateConfigurationResponse, PostTemplateQueryExecuteData, PostTemplateQueryExecuteResponse, GetTemplateQuerySettingsResponse, GetTreeTemplateAncestorsData, GetTreeTemplateAncestorsResponse, GetTreeTemplateChildrenData, GetTreeTemplateChildrenResponse, GetTreeTemplateRootData, GetTreeTemplateRootResponse, PostTemporaryFileData, PostTemporaryFileResponse, GetTemporaryFileByIdData, GetTemporaryFileByIdResponse, DeleteTemporaryFileByIdData, DeleteTemporaryFileByIdResponse, GetTemporaryFileConfigurationResponse, PostUpgradeAuthorizeResponse, GetUpgradeSettingsResponse, GetFilterUserData, GetFilterUserResponse, GetItemUserData, GetItemUserResponse, PostUserData, PostUserResponse, DeleteUserData, DeleteUserResponse, GetUserData, GetUserResponse, GetUserByIdData, GetUserByIdResponse, DeleteUserByIdData, DeleteUserByIdResponse, PutUserByIdData, PutUserByIdResponse, GetUserById2FaData, GetUserById2FaResponse, DeleteUserById2FaByProviderNameData, DeleteUserById2FaByProviderNameResponse, GetUserByIdCalculateStartNodesData, GetUserByIdCalculateStartNodesResponse, PostUserByIdChangePasswordData, PostUserByIdChangePasswordResponse, PostUserByIdClientCredentialsData, PostUserByIdClientCredentialsResponse, GetUserByIdClientCredentialsData, GetUserByIdClientCredentialsResponse, DeleteUserByIdClientCredentialsByClientIdData, DeleteUserByIdClientCredentialsByClientIdResponse, PostUserByIdResetPasswordData, PostUserByIdResetPasswordResponse, DeleteUserAvatarByIdData, DeleteUserAvatarByIdResponse, PostUserAvatarByIdData, PostUserAvatarByIdResponse, GetUserConfigurationResponse, GetUserCurrentResponse, GetUserCurrent2FaResponse, DeleteUserCurrent2FaByProviderNameData, DeleteUserCurrent2FaByProviderNameResponse, PostUserCurrent2FaByProviderNameData, PostUserCurrent2FaByProviderNameResponse, GetUserCurrent2FaByProviderNameData, GetUserCurrent2FaByProviderNameResponse, PostUserCurrentAvatarData, PostUserCurrentAvatarResponse, PostUserCurrentChangePasswordData, PostUserCurrentChangePasswordResponse, GetUserCurrentConfigurationResponse, GetUserCurrentLoginProvidersResponse, GetUserCurrentPermissionsData, GetUserCurrentPermissionsResponse, GetUserCurrentPermissionsDocumentData, GetUserCurrentPermissionsDocumentResponse, GetUserCurrentPermissionsMediaData, GetUserCurrentPermissionsMediaResponse, PostUserDisableData, PostUserDisableResponse, PostUserEnableData, PostUserEnableResponse, PostUserInviteData, PostUserInviteResponse, PostUserInviteCreatePasswordData, PostUserInviteCreatePasswordResponse, PostUserInviteResendData, PostUserInviteResendResponse, PostUserInviteVerifyData, PostUserInviteVerifyResponse, PostUserSetUserGroupsData, PostUserSetUserGroupsResponse, PostUserUnlockData, PostUserUnlockResponse, PostUserDataData, PostUserDataResponse, GetUserDataData, GetUserDataResponse, PutUserDataData, PutUserDataResponse, GetUserDataByIdData, GetUserDataByIdResponse, GetFilterUserGroupData, GetFilterUserGroupResponse, GetItemUserGroupData, GetItemUserGroupResponse, DeleteUserGroupData, DeleteUserGroupResponse, PostUserGroupData, PostUserGroupResponse, GetUserGroupData, GetUserGroupResponse, GetUserGroupByIdData, GetUserGroupByIdResponse, DeleteUserGroupByIdData, DeleteUserGroupByIdResponse, PutUserGroupByIdData, PutUserGroupByIdResponse, DeleteUserGroupByIdUsersData, DeleteUserGroupByIdUsersResponse, PostUserGroupByIdUsersData, PostUserGroupByIdUsersResponse, GetItemWebhookData, GetItemWebhookResponse, GetWebhookData, GetWebhookResponse, PostWebhookData, PostWebhookResponse, GetWebhookByIdData, GetWebhookByIdResponse, DeleteWebhookByIdData, DeleteWebhookByIdResponse, PutWebhookByIdData, PutWebhookByIdResponse, GetWebhookByIdLogsData, GetWebhookByIdLogsResponse, GetWebhookEventsData, GetWebhookEventsResponse, GetWebhookLogsData, GetWebhookLogsResponse } from './types.gen'; +import type { GetCultureData, GetCultureResponse, PostDataTypeData, PostDataTypeResponse, GetDataTypeByIdData, GetDataTypeByIdResponse, DeleteDataTypeByIdData, DeleteDataTypeByIdResponse, PutDataTypeByIdData, PutDataTypeByIdResponse, PostDataTypeByIdCopyData, PostDataTypeByIdCopyResponse, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedResponse, PutDataTypeByIdMoveData, PutDataTypeByIdMoveResponse, GetDataTypeByIdReferencedByData, GetDataTypeByIdReferencedByResponse, GetDataTypeByIdReferencesData, GetDataTypeByIdReferencesResponse, GetDataTypeConfigurationResponse, PostDataTypeFolderData, PostDataTypeFolderResponse, GetDataTypeFolderByIdData, GetDataTypeFolderByIdResponse, DeleteDataTypeFolderByIdData, DeleteDataTypeFolderByIdResponse, PutDataTypeFolderByIdData, PutDataTypeFolderByIdResponse, GetFilterDataTypeData, GetFilterDataTypeResponse, GetItemDataTypeData, GetItemDataTypeResponse, GetItemDataTypeSearchData, GetItemDataTypeSearchResponse, GetTreeDataTypeAncestorsData, GetTreeDataTypeAncestorsResponse, GetTreeDataTypeChildrenData, GetTreeDataTypeChildrenResponse, GetTreeDataTypeRootData, GetTreeDataTypeRootResponse, GetDictionaryData, GetDictionaryResponse, PostDictionaryData, PostDictionaryResponse, GetDictionaryByIdData, GetDictionaryByIdResponse, DeleteDictionaryByIdData, DeleteDictionaryByIdResponse, PutDictionaryByIdData, PutDictionaryByIdResponse, GetDictionaryByIdExportData, GetDictionaryByIdExportResponse, PutDictionaryByIdMoveData, PutDictionaryByIdMoveResponse, PostDictionaryImportData, PostDictionaryImportResponse, GetItemDictionaryData, GetItemDictionaryResponse, GetTreeDictionaryAncestorsData, GetTreeDictionaryAncestorsResponse, GetTreeDictionaryChildrenData, GetTreeDictionaryChildrenResponse, GetTreeDictionaryRootData, GetTreeDictionaryRootResponse, GetCollectionDocumentByIdData, GetCollectionDocumentByIdResponse, PostDocumentData, PostDocumentResponse, GetDocumentByIdData, GetDocumentByIdResponse, DeleteDocumentByIdData, DeleteDocumentByIdResponse, PutDocumentByIdData, PutDocumentByIdResponse, GetDocumentByIdAuditLogData, GetDocumentByIdAuditLogResponse, PostDocumentByIdCopyData, PostDocumentByIdCopyResponse, GetDocumentByIdDomainsData, GetDocumentByIdDomainsResponse, PutDocumentByIdDomainsData, PutDocumentByIdDomainsResponse, PutDocumentByIdMoveData, PutDocumentByIdMoveResponse, PutDocumentByIdMoveToRecycleBinData, PutDocumentByIdMoveToRecycleBinResponse, GetDocumentByIdNotificationsData, GetDocumentByIdNotificationsResponse, PutDocumentByIdNotificationsData, PutDocumentByIdNotificationsResponse, PostDocumentByIdPublicAccessData, PostDocumentByIdPublicAccessResponse, DeleteDocumentByIdPublicAccessData, DeleteDocumentByIdPublicAccessResponse, GetDocumentByIdPublicAccessData, GetDocumentByIdPublicAccessResponse, PutDocumentByIdPublicAccessData, PutDocumentByIdPublicAccessResponse, PutDocumentByIdPublishData, PutDocumentByIdPublishResponse, PutDocumentByIdPublishWithDescendantsData, PutDocumentByIdPublishWithDescendantsResponse, GetDocumentByIdPublishWithDescendantsResultByTaskIdData, GetDocumentByIdPublishWithDescendantsResultByTaskIdResponse, GetDocumentByIdPublishedData, GetDocumentByIdPublishedResponse, GetDocumentByIdReferencedByData, GetDocumentByIdReferencedByResponse, GetDocumentByIdReferencedDescendantsData, GetDocumentByIdReferencedDescendantsResponse, PutDocumentByIdUnpublishData, PutDocumentByIdUnpublishResponse, PutDocumentByIdValidateData, PutDocumentByIdValidateResponse, PutUmbracoManagementApiV11DocumentByIdValidate11Data, PutUmbracoManagementApiV11DocumentByIdValidate11Response, GetDocumentAreReferencedData, GetDocumentAreReferencedResponse, GetDocumentConfigurationResponse, PutDocumentSortData, PutDocumentSortResponse, GetDocumentUrlsData, GetDocumentUrlsResponse, PostDocumentValidateData, PostDocumentValidateResponse, GetItemDocumentData, GetItemDocumentResponse, GetItemDocumentSearchData, GetItemDocumentSearchResponse, DeleteRecycleBinDocumentResponse, DeleteRecycleBinDocumentByIdData, DeleteRecycleBinDocumentByIdResponse, GetRecycleBinDocumentByIdOriginalParentData, GetRecycleBinDocumentByIdOriginalParentResponse, PutRecycleBinDocumentByIdRestoreData, PutRecycleBinDocumentByIdRestoreResponse, GetRecycleBinDocumentChildrenData, GetRecycleBinDocumentChildrenResponse, GetRecycleBinDocumentReferencedByData, GetRecycleBinDocumentReferencedByResponse, GetRecycleBinDocumentRootData, GetRecycleBinDocumentRootResponse, GetTreeDocumentAncestorsData, GetTreeDocumentAncestorsResponse, GetTreeDocumentChildrenData, GetTreeDocumentChildrenResponse, GetTreeDocumentRootData, GetTreeDocumentRootResponse, PostDocumentBlueprintData, PostDocumentBlueprintResponse, GetDocumentBlueprintByIdData, GetDocumentBlueprintByIdResponse, DeleteDocumentBlueprintByIdData, DeleteDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdData, PutDocumentBlueprintByIdResponse, PutDocumentBlueprintByIdMoveData, PutDocumentBlueprintByIdMoveResponse, PostDocumentBlueprintFolderData, PostDocumentBlueprintFolderResponse, GetDocumentBlueprintFolderByIdData, GetDocumentBlueprintFolderByIdResponse, DeleteDocumentBlueprintFolderByIdData, DeleteDocumentBlueprintFolderByIdResponse, PutDocumentBlueprintFolderByIdData, PutDocumentBlueprintFolderByIdResponse, PostDocumentBlueprintFromDocumentData, PostDocumentBlueprintFromDocumentResponse, GetItemDocumentBlueprintData, GetItemDocumentBlueprintResponse, GetTreeDocumentBlueprintAncestorsData, GetTreeDocumentBlueprintAncestorsResponse, GetTreeDocumentBlueprintChildrenData, GetTreeDocumentBlueprintChildrenResponse, GetTreeDocumentBlueprintRootData, GetTreeDocumentBlueprintRootResponse, PostDocumentTypeData, PostDocumentTypeResponse, GetDocumentTypeByIdData, GetDocumentTypeByIdResponse, DeleteDocumentTypeByIdData, DeleteDocumentTypeByIdResponse, PutDocumentTypeByIdData, PutDocumentTypeByIdResponse, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenResponse, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintResponse, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesResponse, PostDocumentTypeByIdCopyData, PostDocumentTypeByIdCopyResponse, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportResponse, PutDocumentTypeByIdImportData, PutDocumentTypeByIdImportResponse, PutDocumentTypeByIdMoveData, PutDocumentTypeByIdMoveResponse, GetDocumentTypeAllowedAtRootData, GetDocumentTypeAllowedAtRootResponse, PostDocumentTypeAvailableCompositionsData, PostDocumentTypeAvailableCompositionsResponse, GetDocumentTypeConfigurationResponse, PostDocumentTypeFolderData, PostDocumentTypeFolderResponse, GetDocumentTypeFolderByIdData, GetDocumentTypeFolderByIdResponse, DeleteDocumentTypeFolderByIdData, DeleteDocumentTypeFolderByIdResponse, PutDocumentTypeFolderByIdData, PutDocumentTypeFolderByIdResponse, PostDocumentTypeImportData, PostDocumentTypeImportResponse, GetItemDocumentTypeData, GetItemDocumentTypeResponse, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchResponse, GetTreeDocumentTypeAncestorsData, GetTreeDocumentTypeAncestorsResponse, GetTreeDocumentTypeChildrenData, GetTreeDocumentTypeChildrenResponse, GetTreeDocumentTypeRootData, GetTreeDocumentTypeRootResponse, GetDocumentVersionData, GetDocumentVersionResponse, GetDocumentVersionByIdData, GetDocumentVersionByIdResponse, PutDocumentVersionByIdPreventCleanupData, PutDocumentVersionByIdPreventCleanupResponse, PostDocumentVersionByIdRollbackData, PostDocumentVersionByIdRollbackResponse, PostDynamicRootQueryData, PostDynamicRootQueryResponse, GetDynamicRootStepsResponse, GetHealthCheckGroupData, GetHealthCheckGroupResponse, GetHealthCheckGroupByNameData, GetHealthCheckGroupByNameResponse, PostHealthCheckGroupByNameCheckData, PostHealthCheckGroupByNameCheckResponse, PostHealthCheckExecuteActionData, PostHealthCheckExecuteActionResponse, GetHelpData, GetHelpResponse, GetImagingResizeUrlsData, GetImagingResizeUrlsResponse, GetImportAnalyzeData, GetImportAnalyzeResponse, GetIndexerData, GetIndexerResponse, GetIndexerByIndexNameData, GetIndexerByIndexNameResponse, PostIndexerByIndexNameRebuildData, PostIndexerByIndexNameRebuildResponse, GetInstallSettingsResponse, PostInstallSetupData, PostInstallSetupResponse, PostInstallValidateDatabaseData, PostInstallValidateDatabaseResponse, GetItemLanguageData, GetItemLanguageResponse, GetItemLanguageDefaultResponse, GetLanguageData, GetLanguageResponse, PostLanguageData, PostLanguageResponse, GetLanguageByIsoCodeData, GetLanguageByIsoCodeResponse, DeleteLanguageByIsoCodeData, DeleteLanguageByIsoCodeResponse, PutLanguageByIsoCodeData, PutLanguageByIsoCodeResponse, GetLogViewerLevelData, GetLogViewerLevelResponse, GetLogViewerLevelCountData, GetLogViewerLevelCountResponse, GetLogViewerLogData, GetLogViewerLogResponse, GetLogViewerMessageTemplateData, GetLogViewerMessageTemplateResponse, GetLogViewerSavedSearchData, GetLogViewerSavedSearchResponse, PostLogViewerSavedSearchData, PostLogViewerSavedSearchResponse, GetLogViewerSavedSearchByNameData, GetLogViewerSavedSearchByNameResponse, DeleteLogViewerSavedSearchByNameData, DeleteLogViewerSavedSearchByNameResponse, GetLogViewerValidateLogsSizeData, GetLogViewerValidateLogsSizeResponse, GetManifestManifestResponse, GetManifestManifestPrivateResponse, GetManifestManifestPublicResponse, GetCollectionMediaData, GetCollectionMediaResponse, GetItemMediaData, GetItemMediaResponse, GetItemMediaSearchData, GetItemMediaSearchResponse, PostMediaData, PostMediaResponse, GetMediaByIdData, GetMediaByIdResponse, DeleteMediaByIdData, DeleteMediaByIdResponse, PutMediaByIdData, PutMediaByIdResponse, GetMediaByIdAuditLogData, GetMediaByIdAuditLogResponse, PutMediaByIdMoveData, PutMediaByIdMoveResponse, PutMediaByIdMoveToRecycleBinData, PutMediaByIdMoveToRecycleBinResponse, GetMediaByIdReferencedByData, GetMediaByIdReferencedByResponse, GetMediaByIdReferencedDescendantsData, GetMediaByIdReferencedDescendantsResponse, PutMediaByIdValidateData, PutMediaByIdValidateResponse, GetMediaAreReferencedData, GetMediaAreReferencedResponse, GetMediaConfigurationResponse, PutMediaSortData, PutMediaSortResponse, GetMediaUrlsData, GetMediaUrlsResponse, PostMediaValidateData, PostMediaValidateResponse, DeleteRecycleBinMediaResponse, DeleteRecycleBinMediaByIdData, DeleteRecycleBinMediaByIdResponse, GetRecycleBinMediaByIdOriginalParentData, GetRecycleBinMediaByIdOriginalParentResponse, PutRecycleBinMediaByIdRestoreData, PutRecycleBinMediaByIdRestoreResponse, GetRecycleBinMediaChildrenData, GetRecycleBinMediaChildrenResponse, GetRecycleBinMediaReferencedByData, GetRecycleBinMediaReferencedByResponse, GetRecycleBinMediaRootData, GetRecycleBinMediaRootResponse, GetTreeMediaAncestorsData, GetTreeMediaAncestorsResponse, GetTreeMediaChildrenData, GetTreeMediaChildrenResponse, GetTreeMediaRootData, GetTreeMediaRootResponse, GetItemMediaTypeData, GetItemMediaTypeResponse, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedResponse, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersResponse, GetItemMediaTypeSearchData, GetItemMediaTypeSearchResponse, PostMediaTypeData, PostMediaTypeResponse, GetMediaTypeByIdData, GetMediaTypeByIdResponse, DeleteMediaTypeByIdData, DeleteMediaTypeByIdResponse, PutMediaTypeByIdData, PutMediaTypeByIdResponse, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenResponse, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesResponse, PostMediaTypeByIdCopyData, PostMediaTypeByIdCopyResponse, GetMediaTypeByIdExportData, GetMediaTypeByIdExportResponse, PutMediaTypeByIdImportData, PutMediaTypeByIdImportResponse, PutMediaTypeByIdMoveData, PutMediaTypeByIdMoveResponse, GetMediaTypeAllowedAtRootData, GetMediaTypeAllowedAtRootResponse, PostMediaTypeAvailableCompositionsData, PostMediaTypeAvailableCompositionsResponse, GetMediaTypeConfigurationResponse, PostMediaTypeFolderData, PostMediaTypeFolderResponse, GetMediaTypeFolderByIdData, GetMediaTypeFolderByIdResponse, DeleteMediaTypeFolderByIdData, DeleteMediaTypeFolderByIdResponse, PutMediaTypeFolderByIdData, PutMediaTypeFolderByIdResponse, PostMediaTypeImportData, PostMediaTypeImportResponse, GetTreeMediaTypeAncestorsData, GetTreeMediaTypeAncestorsResponse, GetTreeMediaTypeChildrenData, GetTreeMediaTypeChildrenResponse, GetTreeMediaTypeRootData, GetTreeMediaTypeRootResponse, GetFilterMemberData, GetFilterMemberResponse, GetItemMemberData, GetItemMemberResponse, GetItemMemberSearchData, GetItemMemberSearchResponse, PostMemberData, PostMemberResponse, GetMemberByIdData, GetMemberByIdResponse, DeleteMemberByIdData, DeleteMemberByIdResponse, PutMemberByIdData, PutMemberByIdResponse, GetMemberByIdReferencedByData, GetMemberByIdReferencedByResponse, GetMemberByIdReferencedDescendantsData, GetMemberByIdReferencedDescendantsResponse, PutMemberByIdValidateData, PutMemberByIdValidateResponse, GetMemberAreReferencedData, GetMemberAreReferencedResponse, GetMemberConfigurationResponse, PostMemberValidateData, PostMemberValidateResponse, GetItemMemberGroupData, GetItemMemberGroupResponse, GetMemberGroupData, GetMemberGroupResponse, PostMemberGroupData, PostMemberGroupResponse, GetMemberGroupByIdData, GetMemberGroupByIdResponse, DeleteMemberGroupByIdData, DeleteMemberGroupByIdResponse, PutMemberGroupByIdData, PutMemberGroupByIdResponse, GetTreeMemberGroupRootData, GetTreeMemberGroupRootResponse, GetItemMemberTypeData, GetItemMemberTypeResponse, GetItemMemberTypeSearchData, GetItemMemberTypeSearchResponse, PostMemberTypeData, PostMemberTypeResponse, GetMemberTypeByIdData, GetMemberTypeByIdResponse, DeleteMemberTypeByIdData, DeleteMemberTypeByIdResponse, PutMemberTypeByIdData, PutMemberTypeByIdResponse, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesResponse, PostMemberTypeByIdCopyData, PostMemberTypeByIdCopyResponse, PostMemberTypeAvailableCompositionsData, PostMemberTypeAvailableCompositionsResponse, GetMemberTypeConfigurationResponse, GetTreeMemberTypeRootData, GetTreeMemberTypeRootResponse, PostModelsBuilderBuildResponse, GetModelsBuilderDashboardResponse, GetModelsBuilderStatusResponse, GetObjectTypesData, GetObjectTypesResponse, GetOembedQueryData, GetOembedQueryResponse, PostPackageByNameRunMigrationData, PostPackageByNameRunMigrationResponse, GetPackageConfigurationResponse, GetPackageCreatedData, GetPackageCreatedResponse, PostPackageCreatedData, PostPackageCreatedResponse, GetPackageCreatedByIdData, GetPackageCreatedByIdResponse, DeletePackageCreatedByIdData, DeletePackageCreatedByIdResponse, PutPackageCreatedByIdData, PutPackageCreatedByIdResponse, GetPackageCreatedByIdDownloadData, GetPackageCreatedByIdDownloadResponse, GetPackageMigrationStatusData, GetPackageMigrationStatusResponse, GetItemPartialViewData, GetItemPartialViewResponse, PostPartialViewData, PostPartialViewResponse, GetPartialViewByPathData, GetPartialViewByPathResponse, DeletePartialViewByPathData, DeletePartialViewByPathResponse, PutPartialViewByPathData, PutPartialViewByPathResponse, PutPartialViewByPathRenameData, PutPartialViewByPathRenameResponse, PostPartialViewFolderData, PostPartialViewFolderResponse, GetPartialViewFolderByPathData, GetPartialViewFolderByPathResponse, DeletePartialViewFolderByPathData, DeletePartialViewFolderByPathResponse, GetPartialViewSnippetData, GetPartialViewSnippetResponse, GetPartialViewSnippetByIdData, GetPartialViewSnippetByIdResponse, GetTreePartialViewAncestorsData, GetTreePartialViewAncestorsResponse, GetTreePartialViewChildrenData, GetTreePartialViewChildrenResponse, GetTreePartialViewRootData, GetTreePartialViewRootResponse, DeletePreviewResponse, PostPreviewResponse, GetProfilingStatusResponse, PutProfilingStatusData, PutProfilingStatusResponse, GetPropertyTypeIsUsedData, GetPropertyTypeIsUsedResponse, PostPublishedCacheRebuildResponse, GetPublishedCacheRebuildStatusResponse, PostPublishedCacheReloadResponse, GetRedirectManagementData, GetRedirectManagementResponse, GetRedirectManagementByIdData, GetRedirectManagementByIdResponse, DeleteRedirectManagementByIdData, DeleteRedirectManagementByIdResponse, GetRedirectManagementStatusResponse, PostRedirectManagementStatusData, PostRedirectManagementStatusResponse, GetRelationByRelationTypeIdData, GetRelationByRelationTypeIdResponse, GetItemRelationTypeData, GetItemRelationTypeResponse, GetRelationTypeData, GetRelationTypeResponse, GetRelationTypeByIdData, GetRelationTypeByIdResponse, GetItemScriptData, GetItemScriptResponse, PostScriptData, PostScriptResponse, GetScriptByPathData, GetScriptByPathResponse, DeleteScriptByPathData, DeleteScriptByPathResponse, PutScriptByPathData, PutScriptByPathResponse, PutScriptByPathRenameData, PutScriptByPathRenameResponse, PostScriptFolderData, PostScriptFolderResponse, GetScriptFolderByPathData, GetScriptFolderByPathResponse, DeleteScriptFolderByPathData, DeleteScriptFolderByPathResponse, GetTreeScriptAncestorsData, GetTreeScriptAncestorsResponse, GetTreeScriptChildrenData, GetTreeScriptChildrenResponse, GetTreeScriptRootData, GetTreeScriptRootResponse, GetSearcherData, GetSearcherResponse, GetSearcherBySearcherNameQueryData, GetSearcherBySearcherNameQueryResponse, GetSecurityConfigurationResponse, PostSecurityForgotPasswordData, PostSecurityForgotPasswordResponse, PostSecurityForgotPasswordResetData, PostSecurityForgotPasswordResetResponse, PostSecurityForgotPasswordVerifyData, PostSecurityForgotPasswordVerifyResponse, GetSegmentData, GetSegmentResponse, GetServerConfigurationResponse, GetServerInformationResponse, GetServerStatusResponse, GetServerTroubleshootingResponse, GetServerUpgradeCheckResponse, GetItemStaticFileData, GetItemStaticFileResponse, GetTreeStaticFileAncestorsData, GetTreeStaticFileAncestorsResponse, GetTreeStaticFileChildrenData, GetTreeStaticFileChildrenResponse, GetTreeStaticFileRootData, GetTreeStaticFileRootResponse, GetItemStylesheetData, GetItemStylesheetResponse, PostStylesheetData, PostStylesheetResponse, GetStylesheetByPathData, GetStylesheetByPathResponse, DeleteStylesheetByPathData, DeleteStylesheetByPathResponse, PutStylesheetByPathData, PutStylesheetByPathResponse, PutStylesheetByPathRenameData, PutStylesheetByPathRenameResponse, PostStylesheetFolderData, PostStylesheetFolderResponse, GetStylesheetFolderByPathData, GetStylesheetFolderByPathResponse, DeleteStylesheetFolderByPathData, DeleteStylesheetFolderByPathResponse, GetTreeStylesheetAncestorsData, GetTreeStylesheetAncestorsResponse, GetTreeStylesheetChildrenData, GetTreeStylesheetChildrenResponse, GetTreeStylesheetRootData, GetTreeStylesheetRootResponse, GetTagData, GetTagResponse, GetTelemetryData, GetTelemetryResponse, GetTelemetryLevelResponse, PostTelemetryLevelData, PostTelemetryLevelResponse, GetItemTemplateData, GetItemTemplateResponse, GetItemTemplateSearchData, GetItemTemplateSearchResponse, PostTemplateData, PostTemplateResponse, GetTemplateByIdData, GetTemplateByIdResponse, DeleteTemplateByIdData, DeleteTemplateByIdResponse, PutTemplateByIdData, PutTemplateByIdResponse, GetTemplateConfigurationResponse, PostTemplateQueryExecuteData, PostTemplateQueryExecuteResponse, GetTemplateQuerySettingsResponse, GetTreeTemplateAncestorsData, GetTreeTemplateAncestorsResponse, GetTreeTemplateChildrenData, GetTreeTemplateChildrenResponse, GetTreeTemplateRootData, GetTreeTemplateRootResponse, PostTemporaryFileData, PostTemporaryFileResponse, GetTemporaryFileByIdData, GetTemporaryFileByIdResponse, DeleteTemporaryFileByIdData, DeleteTemporaryFileByIdResponse, GetTemporaryFileConfigurationResponse, PostUpgradeAuthorizeResponse, GetUpgradeSettingsResponse, GetFilterUserData, GetFilterUserResponse, GetItemUserData, GetItemUserResponse, PostUserData, PostUserResponse, DeleteUserData, DeleteUserResponse, GetUserData, GetUserResponse, GetUserByIdData, GetUserByIdResponse, DeleteUserByIdData, DeleteUserByIdResponse, PutUserByIdData, PutUserByIdResponse, GetUserById2FaData, GetUserById2FaResponse, DeleteUserById2FaByProviderNameData, DeleteUserById2FaByProviderNameResponse, GetUserByIdCalculateStartNodesData, GetUserByIdCalculateStartNodesResponse, PostUserByIdChangePasswordData, PostUserByIdChangePasswordResponse, PostUserByIdClientCredentialsData, PostUserByIdClientCredentialsResponse, GetUserByIdClientCredentialsData, GetUserByIdClientCredentialsResponse, DeleteUserByIdClientCredentialsByClientIdData, DeleteUserByIdClientCredentialsByClientIdResponse, PostUserByIdResetPasswordData, PostUserByIdResetPasswordResponse, DeleteUserAvatarByIdData, DeleteUserAvatarByIdResponse, PostUserAvatarByIdData, PostUserAvatarByIdResponse, GetUserConfigurationResponse, GetUserCurrentResponse, GetUserCurrent2FaResponse, DeleteUserCurrent2FaByProviderNameData, DeleteUserCurrent2FaByProviderNameResponse, PostUserCurrent2FaByProviderNameData, PostUserCurrent2FaByProviderNameResponse, GetUserCurrent2FaByProviderNameData, GetUserCurrent2FaByProviderNameResponse, PostUserCurrentAvatarData, PostUserCurrentAvatarResponse, PostUserCurrentChangePasswordData, PostUserCurrentChangePasswordResponse, GetUserCurrentConfigurationResponse, GetUserCurrentLoginProvidersResponse, GetUserCurrentPermissionsData, GetUserCurrentPermissionsResponse, GetUserCurrentPermissionsDocumentData, GetUserCurrentPermissionsDocumentResponse, GetUserCurrentPermissionsMediaData, GetUserCurrentPermissionsMediaResponse, PostUserDisableData, PostUserDisableResponse, PostUserEnableData, PostUserEnableResponse, PostUserInviteData, PostUserInviteResponse, PostUserInviteCreatePasswordData, PostUserInviteCreatePasswordResponse, PostUserInviteResendData, PostUserInviteResendResponse, PostUserInviteVerifyData, PostUserInviteVerifyResponse, PostUserSetUserGroupsData, PostUserSetUserGroupsResponse, PostUserUnlockData, PostUserUnlockResponse, PostUserDataData, PostUserDataResponse, GetUserDataData, GetUserDataResponse, PutUserDataData, PutUserDataResponse, GetUserDataByIdData, GetUserDataByIdResponse, GetFilterUserGroupData, GetFilterUserGroupResponse, GetItemUserGroupData, GetItemUserGroupResponse, DeleteUserGroupData, DeleteUserGroupResponse, PostUserGroupData, PostUserGroupResponse, GetUserGroupData, GetUserGroupResponse, GetUserGroupByIdData, GetUserGroupByIdResponse, DeleteUserGroupByIdData, DeleteUserGroupByIdResponse, PutUserGroupByIdData, PutUserGroupByIdResponse, DeleteUserGroupByIdUsersData, DeleteUserGroupByIdUsersResponse, PostUserGroupByIdUsersData, PostUserGroupByIdUsersResponse, GetItemWebhookData, GetItemWebhookResponse, GetWebhookData, GetWebhookResponse, PostWebhookData, PostWebhookResponse, GetWebhookByIdData, GetWebhookByIdResponse, DeleteWebhookByIdData, DeleteWebhookByIdResponse, PutWebhookByIdData, PutWebhookByIdResponse, GetWebhookByIdLogsData, GetWebhookByIdLogsResponse, GetWebhookEventsData, GetWebhookEventsResponse, GetWebhookLogsData, GetWebhookLogsResponse } from './types.gen'; export class CultureService { /** @@ -194,6 +194,33 @@ export class DataTypeService { } /** + * @param data The data for the request. + * @param data.id + * @param data.skip + * @param data.take + * @returns unknown OK + * @throws ApiError + */ + public static getDataTypeByIdReferencedBy(data: GetDataTypeByIdReferencedByData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/data-type/{id}/referenced-by', + path: { + id: data.id + }, + query: { + skip: data.skip, + take: data.take + }, + errors: { + 401: 'The resource is protected and requires an authentication token', + 403: 'The authenticated user does not have access to this resource' + } + }); + } + + /** + * @deprecated * @param data The data for the request. * @param data.id * @returns unknown OK @@ -1652,6 +1679,28 @@ export class DocumentService { }); } + /** + * @param data The data for the request. + * @param data.skip + * @param data.take + * @returns unknown OK + * @throws ApiError + */ + public static getRecycleBinDocumentReferencedBy(data: GetRecycleBinDocumentReferencedByData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/recycle-bin/document/referenced-by', + query: { + skip: data.skip, + take: data.take + }, + errors: { + 401: 'The resource is protected and requires an authentication token', + 403: 'The authenticated user does not have access to this resource' + } + }); + } + /** * @param data The data for the request. * @param data.skip @@ -4014,6 +4063,28 @@ export class MediaService { }); } + /** + * @param data The data for the request. + * @param data.skip + * @param data.take + * @returns unknown OK + * @throws ApiError + */ + public static getRecycleBinMediaReferencedBy(data: GetRecycleBinMediaReferencedByData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/recycle-bin/media/referenced-by', + query: { + skip: data.skip, + take: data.take + }, + errors: { + 401: 'The resource is protected and requires an authentication token', + 403: 'The authenticated user does not have access to this resource' + } + }); + } + /** * @param data The data for the request. * @param data.skip diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts index d5d0ba3b42..aadb2351cf 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts @@ -643,6 +643,7 @@ export type DocumentCollectionResponseModel = { documentType: (DocumentTypeCollectionReferenceResponseModel); isTrashed: boolean; isProtected: boolean; + ancestors: Array<(ReferenceByIdModel)>; updater?: (string) | null; }; @@ -715,6 +716,7 @@ export type DocumentTreeItemResponseModel = { id: string; createDate: string; isProtected: boolean; + ancestors: Array<(ReferenceByIdModel)>; documentType: (DocumentTypeReferenceResponseModel); variants: Array<(DocumentVariantItemResponseModel)>; }; @@ -777,6 +779,14 @@ export type DocumentTypePropertyTypeContainerResponseModel = { sortOrder: number; }; +export type DocumentTypePropertyTypeReferenceResponseModel = { + $type: string; + id: string; + name?: (string) | null; + alias?: (string) | null; + documentType: (TrackedReferenceDocumentTypeModel); +}; + export type DocumentTypePropertyTypeResponseModel = { id: string; container?: ((ReferenceByIdModel) | null); @@ -1285,6 +1295,14 @@ export type MediaTypePropertyTypeContainerResponseModel = { sortOrder: number; }; +export type MediaTypePropertyTypeReferenceResponseModel = { + $type: string; + id: string; + name?: (string) | null; + alias?: (string) | null; + mediaType: (TrackedReferenceMediaTypeModel); +}; + export type MediaTypePropertyTypeResponseModel = { id: string; container?: ((ReferenceByIdModel) | null); @@ -1467,6 +1485,14 @@ export type MemberTypePropertyTypeContainerResponseModel = { sortOrder: number; }; +export type MemberTypePropertyTypeReferenceResponseModel = { + $type: string; + id: string; + name?: (string) | null; + alias?: (string) | null; + memberType: (TrackedReferenceMemberTypeModel); +}; + export type MemberTypePropertyTypeResponseModel = { id: string; container?: ((ReferenceByIdModel) | null); @@ -1759,7 +1785,7 @@ export type PagedIndexResponseModel = { export type PagedIReferenceResponseModel = { total: number; - items: Array<(DefaultReferenceResponseModel | DocumentReferenceResponseModel | MediaReferenceResponseModel | MemberReferenceResponseModel)>; + items: Array<(DefaultReferenceResponseModel | DocumentReferenceResponseModel | DocumentTypePropertyTypeReferenceResponseModel | MediaReferenceResponseModel | MediaTypePropertyTypeReferenceResponseModel | MemberReferenceResponseModel | MemberTypePropertyTypeReferenceResponseModel)>; }; export type PagedLanguageResponseModel = { @@ -2409,18 +2435,21 @@ export type TemporaryFileResponseModel = { }; export type TrackedReferenceDocumentTypeModel = { + id: string; icon?: (string) | null; alias?: (string) | null; name?: (string) | null; }; export type TrackedReferenceMediaTypeModel = { + id: string; icon?: (string) | null; alias?: (string) | null; name?: (string) | null; }; export type TrackedReferenceMemberTypeModel = { + id: string; icon?: (string) | null; alias?: (string) | null; name?: (string) | null; @@ -2999,6 +3028,14 @@ export type PutDataTypeByIdMoveData = { export type PutDataTypeByIdMoveResponse = (string); +export type GetDataTypeByIdReferencedByData = { + id: string; + skip?: number; + take?: number; +}; + +export type GetDataTypeByIdReferencedByResponse = ((PagedIReferenceResponseModel)); + export type GetDataTypeByIdReferencesData = { id: string; }; @@ -3417,6 +3454,13 @@ export type GetRecycleBinDocumentChildrenData = { export type GetRecycleBinDocumentChildrenResponse = ((PagedDocumentRecycleBinItemResponseModel)); +export type GetRecycleBinDocumentReferencedByData = { + skip?: number; + take?: number; +}; + +export type GetRecycleBinDocumentReferencedByResponse = ((PagedIReferenceResponseModel)); + export type GetRecycleBinDocumentRootData = { skip?: number; take?: number; @@ -4088,6 +4132,13 @@ export type GetRecycleBinMediaChildrenData = { export type GetRecycleBinMediaChildrenResponse = ((PagedMediaRecycleBinItemResponseModel)); +export type GetRecycleBinMediaReferencedByData = { + skip?: number; + take?: number; +}; + +export type GetRecycleBinMediaReferencedByResponse = ((PagedIReferenceResponseModel)); + export type GetRecycleBinMediaRootData = { skip?: number; take?: number; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.data.ts index 8eadfbca21..d2a1eb9fe8 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.data.ts @@ -6,6 +6,7 @@ export interface UmbMockDocumentBlueprintModel extends UmbMockDocumentModel {} export const data: Array = [ { + ancestors: [], urls: [ { culture: 'en-US', diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts index 611ae9584e..6d6eec6720 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.db.ts @@ -34,6 +34,7 @@ const treeItemMapper = (model: UmbMockDocumentBlueprintModel): Omit = [ { + ancestors: [], urls: [ { culture: 'en-US', @@ -49,6 +50,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [ { culture: 'en-US', @@ -602,6 +604,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [ { culture: 'en-US', @@ -741,6 +744,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [], template: null, id: 'fd56a0b5-01a0-4da2-b428-52773bfa9cc4', @@ -825,6 +829,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [ { culture: 'en-US', @@ -873,6 +878,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [ { culture: 'en-US', @@ -970,6 +976,7 @@ export const data: Array = [ ], }, { + ancestors: [], urls: [ { culture: 'en-US', diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts index 214ed14921..f06f8b1da3 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts @@ -58,6 +58,7 @@ const treeItemMapper = (model: UmbMockDocumentModel): DocumentTreeItemResponseMo if (!documentType) throw new Error(`Document type with id ${model.documentType.id} not found`); return { + ancestors: model.ancestors, documentType: { icon: documentType.icon, id: documentType.id, @@ -80,6 +81,7 @@ const createMockDocumentMapper = (request: CreateDocumentRequestModel): UmbMockD const now = new Date().toString(); return { + ancestors: [], documentType: { id: documentType.id, icon: documentType.icon, @@ -139,6 +141,7 @@ const itemMapper = (model: UmbMockDocumentModel): DocumentItemResponseModel => { const collectionMapper = (model: UmbMockDocumentModel): DocumentCollectionResponseModel => { return { + ancestors: model.ancestors, creator: null, documentType: { id: model.documentType.id, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts index 1137b79c47..5b79b027e3 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts @@ -6,7 +6,10 @@ import type { } from '@umbraco-cms/backoffice/external/backend-api'; export const items: Array< - DefaultReferenceResponseModel | DocumentReferenceResponseModel | MediaReferenceResponseModel | MemberReferenceResponseModel + | DefaultReferenceResponseModel + | DocumentReferenceResponseModel + | MediaReferenceResponseModel + | MemberReferenceResponseModel > = [ { $type: 'DocumentReferenceResponseModel', @@ -17,8 +20,9 @@ export const items: Array< alias: 'blogPost', icon: 'icon-document', name: 'Simple Document Type', + id: 'simple-document-type-id', }, - variants: [] + variants: [], } satisfies DocumentReferenceResponseModel, { $type: 'DocumentReferenceResponseModel', @@ -29,8 +33,9 @@ export const items: Array< alias: 'imageBlock', icon: 'icon-settings', name: 'Image Block', + id: 'image-block-id', }, - variants: [] + variants: [], } satisfies DocumentReferenceResponseModel, { $type: 'MediaReferenceResponseModel', @@ -40,6 +45,7 @@ export const items: Array< alias: 'image', icon: 'icon-picture', name: 'Image', + id: 'media-type-id', }, } satisfies MediaReferenceResponseModel, { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/workspace-info-app.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/workspace-info-app.extension.ts index 19616992aa..0bb567442a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/workspace-info-app.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/workspace-info-app.extension.ts @@ -4,11 +4,11 @@ export interface UmbWorkspaceInfoAppElement extends HTMLElement { manifest?: ManifestWorkspaceInfoApp; } -export interface ManifestWorkspaceInfoApp +export interface ManifestWorkspaceInfoApp extends ManifestElement, ManifestWithDynamicConditions { type: 'workspaceInfoApp'; - meta: MetaWorkspaceInfoApp; + meta: MetaType; } // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/manifests.ts index baae63491c..c61d4c93a7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/manifests.ts @@ -2,6 +2,7 @@ import { UMB_DATA_TYPE_ENTITY_TYPE, UMB_DATA_TYPE_ITEM_REPOSITORY_ALIAS, UMB_DATA_TYPE_DETAIL_REPOSITORY_ALIAS, + UMB_DATA_TYPE_REFERENCE_REPOSITORY_ALIAS, } from '../constants.js'; import { manifests as createManifests } from './create/manifests.js'; import { manifests as moveManifests } from './move-to/manifests.js'; @@ -11,13 +12,14 @@ import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension export const manifests: Array = [ { type: 'entityAction', - kind: 'delete', + kind: 'deleteWithRelation', alias: 'Umb.EntityAction.DataType.Delete', name: 'Delete Data Type Entity Action', forEntityTypes: [UMB_DATA_TYPE_ENTITY_TYPE], meta: { detailRepositoryAlias: UMB_DATA_TYPE_DETAIL_REPOSITORY_ALIAS, itemRepositoryAlias: UMB_DATA_TYPE_ITEM_REPOSITORY_ALIAS, + referenceRepositoryAlias: UMB_DATA_TYPE_REFERENCE_REPOSITORY_ALIAS, }, }, ...createManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/manifests.ts index 1f24894c99..e2085b25ad 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/manifests.ts @@ -3,6 +3,7 @@ import { manifests as dataTypeRootManifest } from './data-type-root/manifests.js import { manifests as entityActions } from './entity-actions/manifests.js'; import { manifests as menuManifests } from './menu/manifests.js'; import { manifests as modalManifests } from './modals/manifests.js'; +import { manifests as referenceManifests } from './reference/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; import { manifests as searchProviderManifests } from './search/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; @@ -15,6 +16,7 @@ export const manifests: Array = ...entityActions, ...menuManifests, ...modalManifests, + ...referenceManifests, ...repositoryManifests, ...searchProviderManifests, ...treeManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/info-app/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/info-app/manifests.ts new file mode 100644 index 0000000000..aceb9124a2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/info-app/manifests.ts @@ -0,0 +1,21 @@ +import { UMB_DATA_TYPE_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_DATA_TYPE_REFERENCE_REPOSITORY_ALIAS } from '../constants.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'workspaceInfoApp', + kind: 'entityReferences', + name: 'Data Type References Workspace Info App', + alias: 'Umb.WorkspaceInfoApp.DataType.References', + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_DATA_TYPE_WORKSPACE_ALIAS, + }, + ], + meta: { + referenceRepositoryAlias: UMB_DATA_TYPE_REFERENCE_REPOSITORY_ALIAS, + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/manifests.ts index 4ac6fbdcb2..cad6350ec8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/manifests.ts @@ -1,3 +1,4 @@ import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as infoAppManifests } from './info-app/manifests.js'; -export const manifests: Array = [...repositoryManifests]; +export const manifests: Array = [...repositoryManifests, ...infoAppManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.repository.ts index 89d330d6c0..336b914ca4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.repository.ts @@ -1,17 +1,9 @@ import { UmbDataTypeReferenceServerDataSource } from './data-type-reference.server.data.js'; -import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; -import type { DataTypeReferenceResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbEntityReferenceRepository } from '@umbraco-cms/backoffice/relations'; -export type UmbDataTypeReferenceModel = { - unique: string; - entityType: string | null; - name: string | null; - icon: string | null; - properties: Array<{ name: string; alias: string }>; -}; - -export class UmbDataTypeReferenceRepository extends UmbControllerBase { +export class UmbDataTypeReferenceRepository extends UmbControllerBase implements UmbEntityReferenceRepository { #referenceSource: UmbDataTypeReferenceServerDataSource; constructor(host: UmbControllerHost) { @@ -19,24 +11,15 @@ export class UmbDataTypeReferenceRepository extends UmbControllerBase { this.#referenceSource = new UmbDataTypeReferenceServerDataSource(this); } - async requestReferencedBy(unique: string) { + async requestReferencedBy(unique: string, skip = 0, take = 20) { if (!unique) throw new Error(`unique is required`); + return this.#referenceSource.getReferencedBy(unique, skip, take); + } - const { data } = await this.#referenceSource.getReferencedBy(unique); - if (!data) return; - - return data.map(mapper); + async requestAreReferenced(uniques: Array, skip = 0, take = 20) { + if (!uniques || uniques.length === 0) throw new Error(`uniques is required`); + return this.#referenceSource.getAreReferenced(uniques, skip, take); } } -const mapper = (item: DataTypeReferenceResponseModel): UmbDataTypeReferenceModel => { - return { - unique: item.contentType.id, - entityType: item.contentType.type, - name: item.contentType.name, - icon: item.contentType.icon, - properties: item.properties, - }; -}; - export default UmbDataTypeReferenceRepository; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.server.data.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.server.data.ts index b94f270e18..d858d68c06 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.server.data.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.server.data.ts @@ -1,30 +1,75 @@ -import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources'; import { DataTypeService } from '@umbraco-cms/backoffice/external/backend-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import type { UmbEntityReferenceDataSource, UmbReferenceItemModel } from '@umbraco-cms/backoffice/relations'; +import type { UmbPagedModel, UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; +import { UmbManagementApiDataMapper } from '@umbraco-cms/backoffice/repository'; /** * @class UmbDataTypeReferenceServerDataSource - * @implements {RepositoryDetailDataSource} + * @implements {UmbEntityReferenceDataSource} */ -export class UmbDataTypeReferenceServerDataSource { - #host: UmbControllerHost; - - /** - * Creates an instance of UmbDataTypeReferenceServerDataSource. - * @param {UmbControllerHost} host - The controller host for this controller to be appended to - * @memberof UmbDataTypeReferenceServerDataSource - */ - constructor(host: UmbControllerHost) { - this.#host = host; - } +export class UmbDataTypeReferenceServerDataSource extends UmbControllerBase implements UmbEntityReferenceDataSource { + #dataMapper = new UmbManagementApiDataMapper(this); /** * Fetches the item for the given unique from the server - * @param {string} id - * @returns {*} + * @param {string} unique - The unique identifier of the item to fetch + * @param {number} skip - The number of items to skip + * @param {number} take - The number of items to take + * @returns {Promise>>} - Items that are referenced by the given unique * @memberof UmbDataTypeReferenceServerDataSource */ - async getReferencedBy(id: string) { - return await tryExecuteAndNotify(this.#host, DataTypeService.getDataTypeByIdReferences({ id })); + async getReferencedBy( + unique: string, + skip = 0, + take = 20, + ): Promise>> { + const { data, error } = await tryExecuteAndNotify( + this, + DataTypeService.getDataTypeByIdReferencedBy({ id: unique, skip, take }), + ); + + if (data) { + const promises = data.items.map(async (item) => { + return this.#dataMapper.map({ + forDataModel: item.$type, + data: item, + fallback: async () => { + return { + ...item, + unique: item.id, + entityType: 'unknown', + }; + }, + }); + }); + + const items = await Promise.all(promises); + + return { data: { items, total: data.total } }; + } + + return { data, error }; + } + + /** + * Checks if the items are referenced by other items + * @param {Array} uniques - The unique identifiers of the items to fetch + * @param {number} skip - The number of items to skip + * @param {number} take - The number of items to take + * @returns {Promise>>} - Items that are referenced by other items + * @memberof UmbDataTypeReferenceServerDataSource + */ + async getAreReferenced( + uniques: Array, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + skip: number = 0, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + take: number = 20, + ): Promise>> { + console.warn('getAreReferenced is not implemented for DataTypeReferenceServerDataSource'); + return { data: { items: [], total: 0 } }; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/data-type-workspace-view-info-reference.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/data-type-workspace-view-info-reference.element.ts deleted file mode 100644 index a1f0d2476f..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/data-type-workspace-view-info-reference.element.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { UmbDataTypeReferenceRepository } from '../../../reference/index.js'; -import type { UmbDataTypeReferenceModel } from '../../../reference/index.js'; -import { css, html, customElement, state, repeat, property, when } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; -import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; -import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; - -const elementName = 'umb-data-type-workspace-view-info-reference'; - -@customElement(elementName) -export class UmbDataTypeWorkspaceViewInfoReferenceElement extends UmbLitElement { - #referenceRepository = new UmbDataTypeReferenceRepository(this); - - #routeBuilder?: UmbModalRouteBuilder; - - @property() - dataTypeUnique = ''; - - @state() - private _loading = true; - - @state() - private _items?: Array = []; - - constructor() { - super(); - - new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) - .addAdditionalPath(':entityType') - .onSetup((params) => { - return { data: { entityType: params.entityType, preset: {} } }; - }) - .observeRouteBuilder((routeBuilder) => { - this.#routeBuilder = routeBuilder; - }); - } - - protected override firstUpdated() { - this.#getReferences(); - } - - async #getReferences() { - this._loading = true; - - const items = await this.#referenceRepository.requestReferencedBy(this.dataTypeUnique); - if (!items) return; - - this._items = items; - this._loading = false; - } - - override render() { - return html` - - ${when( - this._loading, - () => html``, - () => this.#renderItems(), - )} - - `; - } - - #getEditPath(item: UmbDataTypeReferenceModel) { - // TODO: [LK] Ask NL for a reminder on how the route constants work. - return this.#routeBuilder && item.entityType - ? this.#routeBuilder({ entityType: item.entityType }) + `edit/${item.unique}` - : '#'; - } - - #renderItems() { - if (!this._items?.length) return html`

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

`; - return html` - - - Name - Type - - Referenced by - - - ${repeat( - this._items, - (item) => item.unique, - (item) => html` - - - - - - - ${item.entityType} - ${item.properties.map((prop) => prop.name).join(', ')} - - `, - )} - - `; - } - - static override styles = [ - UmbTextStyles, - css` - :host { - display: contents; - } - uui-table-cell { - color: var(--uui-color-text-alt); - } - `, - ]; -} - -export { UmbDataTypeWorkspaceViewInfoReferenceElement as element }; - -declare global { - interface HTMLElementTagNameMap { - [elementName]: UmbDataTypeWorkspaceViewInfoReferenceElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/workspace-view-data-type-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/workspace-view-data-type-info.element.ts index 81e6eb9046..e6814696a8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/workspace-view-data-type-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/workspace/views/info/workspace-view-data-type-info.element.ts @@ -4,8 +4,6 @@ import { css, html, customElement, state } from '@umbraco-cms/backoffice/externa import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'; -import './data-type-workspace-view-info-reference.element.js'; - @customElement('umb-workspace-view-data-type-info') export class UmbWorkspaceViewDataTypeInfoElement extends UmbLitElement implements UmbWorkspaceViewElement { @state() @@ -47,8 +45,7 @@ export class UmbWorkspaceViewDataTypeInfoElement extends UmbLitElement implement override render() { return html`
- +
${this.#renderGeneralInfo()}
`; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/constants.ts index 1b4981f65a..d07e60d102 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/constants.ts @@ -1,7 +1,8 @@ -export * from './paths.js'; export * from './entity-actions/constants.js'; -export * from './search/constants.js'; +export * from './paths.js'; +export * from './property-type/constants.js'; export * from './repository/constants.js'; +export * from './search/constants.js'; export * from './tree/constants.js'; export * from './workspace/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/manifests.ts index 3562f9d6a7..1fea74a755 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/manifests.ts @@ -1,6 +1,7 @@ import { manifests as entityActionsManifests } from './entity-actions/manifests.js'; import { manifests as menuManifests } from './menu/manifests.js'; import { manifests as propertyEditorManifests } from './property-editors/manifests.js'; +import { manifests as propertyTypeManifests } from './property-type/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; import { manifests as searchManifests } from './search/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; @@ -11,6 +12,7 @@ export const manifests: Array = ...entityActionsManifests, ...menuManifests, ...propertyEditorManifests, + ...propertyTypeManifests, ...repositoryManifests, ...searchManifests, ...treeManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/constants.ts new file mode 100644 index 0000000000..e86be43738 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/constants.ts @@ -0,0 +1 @@ +export { UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-item-ref.element.ts new file mode 100644 index 0000000000..a841c162c9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-item-ref.element.ts @@ -0,0 +1,80 @@ +import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '../entity.js'; +import { UMB_EDIT_DOCUMENT_TYPE_WORKSPACE_PATH_PATTERN } from '../paths.js'; +import type { UmbDocumentTypePropertyTypeReferenceModel } from './types.js'; +import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; + +@customElement('umb-document-type-property-type-item-ref') +export class UmbDocumentTypePropertyTypeItemRefElement extends UmbLitElement { + @property({ type: Object }) + item?: UmbDocumentTypePropertyTypeReferenceModel; + + @property({ type: Boolean }) + readonly = false; + + @property({ type: Boolean }) + standalone = false; + + @state() + _editPath = ''; + + constructor() { + super(); + + new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) + .addUniquePaths(['unique']) + .onSetup(() => { + return { data: { entityType: UMB_DOCUMENT_TYPE_ENTITY_TYPE, preset: {} } }; + }) + .observeRouteBuilder((routeBuilder) => { + this._editPath = routeBuilder({}); + }); + } + + #getHref() { + if (!this.item?.unique) return; + const path = UMB_EDIT_DOCUMENT_TYPE_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this.item.documentType.unique }); + return `${this._editPath}/${path}`; + } + + #getName() { + const documentTypeName = this.item?.documentType.name ?? 'Unknown'; + return `Document Type: ${documentTypeName}`; + } + + #getDetail() { + const propertyTypeDetails = this.item?.name ? this.item.name + ' (' + this.item.alias + ')' : 'Unknown'; + return `Property Type: ${propertyTypeDetails}`; + } + + override render() { + if (!this.item) return nothing; + + return html` + + + ${this.#renderIcon()} + + `; + } + + #renderIcon() { + if (!this.item?.documentType.icon) return nothing; + return html``; + } +} + +export { UmbDocumentTypePropertyTypeItemRefElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-document-type-property-type-item-ref': UmbDocumentTypePropertyTypeItemRefElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-reference-response.management-api.mapping.ts new file mode 100644 index 0000000000..87cd65c479 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/document-type-property-type-reference-response.management-api.mapping.ts @@ -0,0 +1,28 @@ +import type { UmbDocumentTypePropertyTypeReferenceModel } from './types.js'; +import { UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import type { DocumentTypePropertyTypeReferenceResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbDataSourceDataMapping } from '@umbraco-cms/backoffice/repository'; + +export class UmbDocumentTypePropertyTypeReferenceResponseManagementApiDataMapping + extends UmbControllerBase + implements + UmbDataSourceDataMapping +{ + async map(data: DocumentTypePropertyTypeReferenceResponseModel): Promise { + return { + alias: data.alias!, + documentType: { + alias: data.documentType.alias!, + icon: data.documentType.icon!, + name: data.documentType.name!, + unique: data.documentType.id, + }, + entityType: UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE, + name: data.name!, + unique: data.id, + }; + } +} + +export { UmbDocumentTypePropertyTypeReferenceResponseManagementApiDataMapping as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/entity.ts new file mode 100644 index 0000000000..be229d94cd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/entity.ts @@ -0,0 +1,3 @@ +export const UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE = 'document-type-property-type'; + +export type UmbDocumentTypePropertyTypeEntityType = typeof UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/manifests.ts new file mode 100644 index 0000000000..e09a5cb902 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/manifests.ts @@ -0,0 +1,20 @@ +import { UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import { UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS } from '@umbraco-cms/backoffice/repository'; + +export const manifests: Array = [ + { + type: 'dataSourceDataMapping', + alias: 'Umb.DataSourceDataMapping.ManagementApi.DocumentTypePropertyTypeReferenceResponse', + name: 'Document Type Property Type Reference Response Management Api Data Mapping', + api: () => import('./document-type-property-type-reference-response.management-api.mapping.js'), + forDataSource: UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS, + forDataModel: 'DocumentTypePropertyTypeReferenceResponseModel', + }, + { + type: 'entityItemRef', + alias: 'Umb.EntityItemRef.DocumentTypePropertyType', + name: 'Document Type Property Type Entity Item Reference', + element: () => import('./document-type-property-type-item-ref.element.js'), + forEntityTypes: [UMB_DOCUMENT_TYPE_PROPERTY_TYPE_ENTITY_TYPE], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/types.ts new file mode 100644 index 0000000000..8668fbd8ba --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-type/types.ts @@ -0,0 +1,12 @@ +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbDocumentTypePropertyTypeReferenceModel extends UmbEntityModel { + alias: string; + documentType: { + alias: string; + icon: string; + name: string; + unique: string; + }; + name: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/manifests.ts index 42f6e34b19..d35bb15071 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/manifests.ts @@ -1,18 +1,21 @@ import { UMB_DOCUMENT_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_DOCUMENT_REFERENCE_REPOSITORY_ALIAS } from '../constants.js'; import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; export const manifests: Array = [ { type: 'workspaceInfoApp', + kind: 'entityReferences', name: 'Document References Workspace Info App', alias: 'Umb.WorkspaceInfoApp.Document.References', - element: () => import('./document-references-workspace-view-info.element.js'), - weight: 90, conditions: [ { alias: UMB_WORKSPACE_CONDITION_ALIAS, match: UMB_DOCUMENT_WORKSPACE_ALIAS, }, ], + meta: { + referenceRepositoryAlias: UMB_DOCUMENT_REFERENCE_REPOSITORY_ALIAS, + }, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/repository/document-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/repository/document-reference-response.management-api.mapping.ts index 5dc5b08754..ecc3b4fd23 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/repository/document-reference-response.management-api.mapping.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/repository/document-reference-response.management-api.mapping.ts @@ -14,9 +14,10 @@ export class UmbDocumentReferenceResponseManagementApiDataMapping async map(data: DocumentReferenceResponseModel): Promise { return { documentType: { - alias: data.documentType.alias, - icon: data.documentType.icon, - name: data.documentType.name, + alias: data.documentType.alias!, + icon: data.documentType.icon!, + name: data.documentType.name!, + unique: data.documentType.id, }, entityType: UMB_DOCUMENT_ENTITY_TYPE, id: data.id, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/types.ts index 90496f5d87..efd670dd1f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/types.ts @@ -1,6 +1,5 @@ import type { UmbDocumentItemVariantModel } from '../item/types.js'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; -import type { TrackedReferenceDocumentTypeModel } from '@umbraco-cms/backoffice/external/backend-api'; export interface UmbDocumentReferenceModel extends UmbEntityModel { /** @@ -23,6 +22,11 @@ export interface UmbDocumentReferenceModel extends UmbEntityModel { * @memberof UmbDocumentReferenceModel */ published?: boolean | null; - documentType: TrackedReferenceDocumentTypeModel; + documentType: { + alias: string; + icon: string; + name: string; + unique: string; + }; variants: Array; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/constants.ts index 53e68e6f83..5bc61b44e3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/constants.ts @@ -1,6 +1,7 @@ export * from './entity-actions/constants.js'; export * from './media-type-root/constants.js'; export * from './paths.js'; +export * from './property-type/constants.js'; export * from './repository/constants.js'; export * from './tree/constants.js'; export * from './workspace/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/manifests.ts index 49a80defb6..a1a7a478c8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/manifests.ts @@ -1,18 +1,20 @@ import { manifests as entityActionsManifests } from './entity-actions/manifests.js'; import { manifests as menuManifests } from './menu/manifests.js'; +import { manifests as propertyEditorUiManifests } from './property-editors/manifests.js'; +import { manifests as propertyTypeManifests } from './property-type/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as searchManifests } from './search/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; import { manifests as workspaceManifests } from './workspace/manifests.js'; -import { manifests as propertyEditorUiManifests } from './property-editors/manifests.js'; -import { manifests as searchManifests } from './search/manifests.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; export const manifests: Array = [ ...entityActionsManifests, ...menuManifests, + ...propertyEditorUiManifests, + ...propertyTypeManifests, ...repositoryManifests, + ...searchManifests, ...treeManifests, ...workspaceManifests, - ...propertyEditorUiManifests, - ...searchManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/constants.ts new file mode 100644 index 0000000000..e6a1ec27f1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/constants.ts @@ -0,0 +1 @@ +export { UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/entity.ts new file mode 100644 index 0000000000..7614ba1a74 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/entity.ts @@ -0,0 +1,3 @@ +export const UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE = 'media-type-property-type'; + +export type UmbMediaTypePropertyTypeEntityType = typeof UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/manifests.ts new file mode 100644 index 0000000000..4a83872ffe --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/manifests.ts @@ -0,0 +1,20 @@ +import { UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import { UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS } from '@umbraco-cms/backoffice/repository'; + +export const manifests: Array = [ + { + type: 'dataSourceDataMapping', + alias: 'Umb.DataSourceDataMapping.ManagementApi.MediaTypePropertyTypeReferenceResponse', + name: 'Media Type Property Type Reference Response Management Api Data Mapping', + api: () => import('./media-type-property-type-reference-response.management-api.mapping.js'), + forDataSource: UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS, + forDataModel: 'MediaTypePropertyTypeReferenceResponseModel', + }, + { + type: 'entityItemRef', + alias: 'Umb.EntityItemRef.MediaTypePropertyType', + name: 'Media Type Property Type Entity Item Reference', + element: () => import('./media-type-property-type-item-ref.element.js'), + forEntityTypes: [UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-item-ref.element.ts new file mode 100644 index 0000000000..76bb1176a5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-item-ref.element.ts @@ -0,0 +1,80 @@ +import { UMB_MEDIA_TYPE_ENTITY_TYPE } from '../entity.js'; +import { UMB_EDIT_MEDIA_TYPE_WORKSPACE_PATH_PATTERN } from '../paths.js'; +import type { UmbMediaTypePropertyTypeReferenceModel } from './types.js'; +import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; + +@customElement('umb-media-type-property-type-item-ref') +export class UmbMediaTypePropertyTypeItemRefElement extends UmbLitElement { + @property({ type: Object }) + item?: UmbMediaTypePropertyTypeReferenceModel; + + @property({ type: Boolean }) + readonly = false; + + @property({ type: Boolean }) + standalone = false; + + @state() + _editPath = ''; + + constructor() { + super(); + + new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) + .addUniquePaths(['unique']) + .onSetup(() => { + return { data: { entityType: UMB_MEDIA_TYPE_ENTITY_TYPE, preset: {} } }; + }) + .observeRouteBuilder((routeBuilder) => { + this._editPath = routeBuilder({}); + }); + } + + #getHref() { + if (!this.item?.unique) return; + const path = UMB_EDIT_MEDIA_TYPE_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this.item.mediaType.unique }); + return `${this._editPath}/${path}`; + } + + #getName() { + const mediaTypeName = this.item?.mediaType.name ?? 'Unknown'; + return `Media Type: ${mediaTypeName}`; + } + + #getDetail() { + const propertyTypeDetails = this.item?.name ? this.item.name + ' (' + this.item.alias + ')' : 'Unknown'; + return `Property Type: ${propertyTypeDetails}`; + } + + override render() { + if (!this.item) return nothing; + + return html` + + + ${this.#renderIcon()} + + `; + } + + #renderIcon() { + if (!this.item?.mediaType.icon) return nothing; + return html``; + } +} + +export { UmbMediaTypePropertyTypeItemRefElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-media-type-property-type-item-ref': UmbMediaTypePropertyTypeItemRefElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-reference-response.management-api.mapping.ts new file mode 100644 index 0000000000..641fe13ba7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/media-type-property-type-reference-response.management-api.mapping.ts @@ -0,0 +1,28 @@ +import type { UmbMediaTypePropertyTypeReferenceModel } from './types.js'; +import { UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import type { MediaTypePropertyTypeReferenceResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbDataSourceDataMapping } from '@umbraco-cms/backoffice/repository'; + +export class UmbMediaTypePropertyTypeReferenceResponseManagementApiDataMapping + extends UmbControllerBase + implements + UmbDataSourceDataMapping +{ + async map(data: MediaTypePropertyTypeReferenceResponseModel): Promise { + return { + alias: data.alias!, + mediaType: { + alias: data.mediaType.alias!, + icon: data.mediaType.icon!, + name: data.mediaType.name!, + unique: data.mediaType.id, + }, + entityType: UMB_MEDIA_TYPE_PROPERTY_TYPE_ENTITY_TYPE, + name: data.name!, + unique: data.id, + }; + } +} + +export { UmbMediaTypePropertyTypeReferenceResponseManagementApiDataMapping as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/types.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/types.ts new file mode 100644 index 0000000000..b314e7cf6f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/property-type/types.ts @@ -0,0 +1,12 @@ +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbMediaTypePropertyTypeReferenceModel extends UmbEntityModel { + alias: string; + mediaType: { + alias: string; + icon: string; + name: string; + unique: string; + }; + name: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/manifests.ts index 6740bdc27f..bc56ed6951 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/manifests.ts @@ -1,18 +1,21 @@ import { UMB_MEDIA_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_MEDIA_REFERENCE_REPOSITORY_ALIAS } from '../constants.js'; import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; export const manifests: Array = [ { type: 'workspaceInfoApp', + kind: 'entityReferences', name: 'Media References Workspace Info App', alias: 'Umb.WorkspaceInfoApp.Media.References', - element: () => import('./media-references-workspace-info-app.element.js'), - weight: 90, conditions: [ { alias: UMB_WORKSPACE_CONDITION_ALIAS, match: UMB_MEDIA_WORKSPACE_ALIAS, }, ], + meta: { + referenceRepositoryAlias: UMB_MEDIA_REFERENCE_REPOSITORY_ALIAS, + }, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/media-references-workspace-info-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/media-references-workspace-info-app.element.ts deleted file mode 100644 index 5a6151a8c4..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/info-app/media-references-workspace-info-app.element.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { UmbMediaReferenceRepository } from '../repository/index.js'; -import { UMB_MEDIA_WORKSPACE_CONTEXT } from '../../workspace/constants.js'; -import { css, customElement, html, nothing, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { UmbReferenceItemModel } from '@umbraco-cms/backoffice/relations'; -import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; -import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; - -@customElement('umb-media-references-workspace-info-app') -export class UmbMediaReferencesWorkspaceInfoAppElement extends UmbLitElement { - #itemsPerPage = 10; - - #referenceRepository; - - @state() - private _currentPage = 1; - - @state() - private _total = 0; - - @state() - private _items?: Array = []; - - @state() - private _loading = true; - - #workspaceContext?: typeof UMB_MEDIA_WORKSPACE_CONTEXT.TYPE; - #mediaUnique?: UmbEntityUnique; - - constructor() { - super(); - this.#referenceRepository = new UmbMediaReferenceRepository(this); - - this.consumeContext(UMB_MEDIA_WORKSPACE_CONTEXT, (context) => { - this.#workspaceContext = context; - this.#observeMediaUnique(); - }); - } - - #observeMediaUnique() { - this.observe( - this.#workspaceContext?.unique, - (unique) => { - if (!unique) { - this.#mediaUnique = undefined; - this._items = []; - return; - } - - if (this.#mediaUnique === unique) { - return; - } - - this.#mediaUnique = unique; - this.#getReferences(); - }, - 'umbReferencesDocumentUniqueObserver', - ); - } - - async #getReferences() { - if (!this.#mediaUnique) { - throw new Error('Media unique is required'); - } - - this._loading = true; - - const { data } = await this.#referenceRepository.requestReferencedBy( - this.#mediaUnique, - (this._currentPage - 1) * this.#itemsPerPage, - this.#itemsPerPage, - ); - - if (!data) return; - - this._total = data.total; - this._items = data.items; - - this._loading = false; - } - - #onPageChange(event: UUIPaginationEvent) { - if (this._currentPage === event.target.current) return; - this._currentPage = event.target.current; - - this.#getReferences(); - } - - override render() { - if (!this._items?.length) return nothing; - return html` - - ${when( - this._loading, - () => html``, - () => html`${this.#renderItems()} ${this.#renderPagination()}`, - )} - - `; - } - - #renderItems() { - if (!this._items) return; - return html` - - ${repeat( - this._items, - (item) => item.unique, - (item) => html``, - )} - - `; - } - - #renderPagination() { - if (!this._total) return nothing; - - const totalPages = Math.ceil(this._total / this.#itemsPerPage); - - if (totalPages <= 1) return nothing; - - return html` - - `; - } - - static override styles = [ - UmbTextStyles, - css` - :host { - display: contents; - } - - uui-table-cell { - color: var(--uui-color-text-alt); - } - - uui-pagination { - flex: 1; - display: inline-block; - } - - .pagination { - display: flex; - justify-content: center; - margin-top: var(--uui-size-space-4); - } - `, - ]; -} - -export default UmbMediaReferencesWorkspaceInfoAppElement; - -declare global { - interface HTMLElementTagNameMap { - 'umb-media-references-workspace-info-app': UmbMediaReferencesWorkspaceInfoAppElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/media-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/media-reference-response.management-api.mapping.ts index e9c29762e5..34a7a83d7a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/media-reference-response.management-api.mapping.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/media-reference-response.management-api.mapping.ts @@ -13,9 +13,10 @@ export class UmbMediaReferenceResponseManagementApiDataMapping entityType: UMB_MEDIA_ENTITY_TYPE, id: data.id, mediaType: { - alias: data.mediaType.alias, - icon: data.mediaType.icon, - name: data.mediaType.name, + alias: data.mediaType.alias!, + icon: data.mediaType.icon!, + name: data.mediaType.name!, + unique: data.mediaType.id, }, name: data.name, // TODO: this is a hardcoded array until the server can return the correct variants array diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/types.ts index 69220e6301..fa9ef98990 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/reference/repository/types.ts @@ -1,6 +1,5 @@ import type { UmbMediaItemVariantModel } from '../../repository/item/types.js'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; -import type { TrackedReferenceMediaTypeModel } from '@umbraco-cms/backoffice/external/backend-api'; export interface UmbMediaReferenceModel extends UmbEntityModel { /** @@ -23,6 +22,11 @@ export interface UmbMediaReferenceModel extends UmbEntityModel { * @memberof UmbMediaReferenceModel */ published?: boolean | null; - mediaType: TrackedReferenceMediaTypeModel; + mediaType: { + alias: string; + icon: string; + name: string; + unique: string; + }; variants: Array; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/constants.ts index 75fe65204b..8e60cb02fa 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/constants.ts @@ -1,7 +1,8 @@ export * from './entity-actions/constants.js'; +export * from './paths.js'; +export * from './property-type/constants.js'; export * from './repository/constants.js'; export * from './tree/constants.js'; export * from './workspace/constants.js'; -export * from './paths.js'; export { UMB_MEMBER_TYPE_ROOT_ENTITY_TYPE, UMB_MEMBER_TYPE_ENTITY_TYPE } from './entity.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/manifests.ts index 96b4dceda1..ba33f43db6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/manifests.ts @@ -1,5 +1,6 @@ import { manifests as entityActionsManifests } from './entity-actions/manifests.js'; import { manifests as menuManifests } from './menu/manifests.js'; +import { manifests as propertyTypeManifests } from './property-type/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; import { manifests as searchManifests } from './search/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; @@ -11,6 +12,7 @@ import './components/index.js'; export const manifests: Array = [ ...entityActionsManifests, ...menuManifests, + ...propertyTypeManifests, ...repositoryManifests, ...searchManifests, ...treeManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/constants.ts new file mode 100644 index 0000000000..ce977e948b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/constants.ts @@ -0,0 +1 @@ +export { UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/entity.ts new file mode 100644 index 0000000000..dd0d0a29e0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/entity.ts @@ -0,0 +1,3 @@ +export const UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE = 'member-type-property-type'; + +export type UmbMemberTypePropertyTypeEntityType = typeof UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/manifests.ts new file mode 100644 index 0000000000..7b50fb5c2d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/manifests.ts @@ -0,0 +1,20 @@ +import { UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import { UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS } from '@umbraco-cms/backoffice/repository'; + +export const manifests: Array = [ + { + type: 'dataSourceDataMapping', + alias: 'Umb.DataSourceDataMapping.ManagementApi.MemberTypePropertyTypeReferenceResponse', + name: 'Member Type Property Type Reference Response Management Api Data Mapping', + api: () => import('./member-type-property-type-reference-response.management-api.mapping.js'), + forDataSource: UMB_MANAGEMENT_API_DATA_SOURCE_ALIAS, + forDataModel: 'MemberTypePropertyTypeReferenceResponseModel', + }, + { + type: 'entityItemRef', + alias: 'Umb.EntityItemRef.MemberTypePropertyType', + name: 'Member Type Property Type Entity Item Reference', + element: () => import('./member-type-property-type-item-ref.element.js'), + forEntityTypes: [UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-item-ref.element.ts new file mode 100644 index 0000000000..e55489b82a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-item-ref.element.ts @@ -0,0 +1,80 @@ +import { UMB_MEMBER_TYPE_ENTITY_TYPE } from '../entity.js'; +import { UMB_EDIT_MEMBER_TYPE_WORKSPACE_PATH_PATTERN } from '../paths.js'; +import type { UmbMemberTypePropertyTypeReferenceModel } from './types.js'; +import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; + +@customElement('umb-member-type-property-type-item-ref') +export class UmbMemberTypePropertyTypeItemRefElement extends UmbLitElement { + @property({ type: Object }) + item?: UmbMemberTypePropertyTypeReferenceModel; + + @property({ type: Boolean }) + readonly = false; + + @property({ type: Boolean }) + standalone = false; + + @state() + _editPath = ''; + + constructor() { + super(); + + new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) + .addUniquePaths(['unique']) + .onSetup(() => { + return { data: { entityType: UMB_MEMBER_TYPE_ENTITY_TYPE, preset: {} } }; + }) + .observeRouteBuilder((routeBuilder) => { + this._editPath = routeBuilder({}); + }); + } + + #getHref() { + if (!this.item?.unique) return; + const path = UMB_EDIT_MEMBER_TYPE_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this.item.memberType.unique }); + return `${this._editPath}/${path}`; + } + + #getName() { + const memberTypeName = this.item?.memberType.name ?? 'Unknown'; + return `Member Type: ${memberTypeName}`; + } + + #getDetail() { + const propertyTypeDetails = this.item?.name ? this.item.name + ' (' + this.item.alias + ')' : 'Unknown'; + return `Property Type: ${propertyTypeDetails}`; + } + + override render() { + if (!this.item) return nothing; + + return html` + + + ${this.#renderIcon()} + + `; + } + + #renderIcon() { + if (!this.item?.memberType.icon) return nothing; + return html``; + } +} + +export { UmbMemberTypePropertyTypeItemRefElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-member-type-property-type-item-ref': UmbMemberTypePropertyTypeItemRefElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-reference-response.management-api.mapping.ts new file mode 100644 index 0000000000..17ee9a86a5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/member-type-property-type-reference-response.management-api.mapping.ts @@ -0,0 +1,28 @@ +import type { UmbMemberTypePropertyTypeReferenceModel } from './types.js'; +import { UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE } from './entity.js'; +import type { MemberTypePropertyTypeReferenceResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbDataSourceDataMapping } from '@umbraco-cms/backoffice/repository'; + +export class UmbMemberTypePropertyTypeReferenceResponseManagementApiDataMapping + extends UmbControllerBase + implements + UmbDataSourceDataMapping +{ + async map(data: MemberTypePropertyTypeReferenceResponseModel): Promise { + return { + alias: data.alias!, + memberType: { + alias: data.memberType.alias!, + icon: data.memberType.icon!, + name: data.memberType.name!, + unique: data.memberType.id, + }, + entityType: UMB_MEMBER_TYPE_PROPERTY_TYPE_ENTITY_TYPE, + name: data.name!, + unique: data.id, + }; + } +} + +export { UmbMemberTypePropertyTypeReferenceResponseManagementApiDataMapping as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/types.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/types.ts new file mode 100644 index 0000000000..566b91fe24 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/property-type/types.ts @@ -0,0 +1,12 @@ +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbMemberTypePropertyTypeReferenceModel extends UmbEntityModel { + alias: string; + memberType: { + alias: string; + icon: string; + name: string; + unique: string; + }; + name: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/manifests.ts index 7ebbb37e12..518dec22ab 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/manifests.ts @@ -1,18 +1,21 @@ import { UMB_MEMBER_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { UMB_MEMBER_REFERENCE_REPOSITORY_ALIAS } from '../constants.js'; import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; export const manifests: Array = [ { type: 'workspaceInfoApp', + kind: 'entityReferences', name: 'Member References Workspace Info App', alias: 'Umb.WorkspaceInfoApp.Member.References', - element: () => import('./member-references-workspace-info-app.element.js'), - weight: 90, conditions: [ { alias: UMB_WORKSPACE_CONDITION_ALIAS, match: UMB_MEMBER_WORKSPACE_ALIAS, }, ], + meta: { + referenceRepositoryAlias: UMB_MEMBER_REFERENCE_REPOSITORY_ALIAS, + }, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/member-references-workspace-info-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/member-references-workspace-info-app.element.ts deleted file mode 100644 index 23828e7026..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/info-app/member-references-workspace-info-app.element.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { UmbMemberReferenceRepository } from '../repository/index.js'; -import { UMB_MEMBER_WORKSPACE_CONTEXT } from '../../workspace/constants.js'; -import { css, customElement, html, nothing, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { UmbReferenceItemModel } from '@umbraco-cms/backoffice/relations'; -import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; -import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; - -@customElement('umb-member-references-workspace-info-app') -export class UmbMemberReferencesWorkspaceInfoAppElement extends UmbLitElement { - #itemsPerPage = 10; - - #referenceRepository; - - @state() - private _currentPage = 1; - - @state() - private _total = 0; - - @state() - private _items?: Array = []; - - @state() - private _loading = true; - - #workspaceContext?: typeof UMB_MEMBER_WORKSPACE_CONTEXT.TYPE; - #memberUnique?: UmbEntityUnique; - - constructor() { - super(); - this.#referenceRepository = new UmbMemberReferenceRepository(this); - - this.consumeContext(UMB_MEMBER_WORKSPACE_CONTEXT, (context) => { - this.#workspaceContext = context; - this.#observeMemberUnique(); - }); - } - - #observeMemberUnique() { - this.observe( - this.#workspaceContext?.unique, - (unique) => { - if (!unique) { - this.#memberUnique = undefined; - this._items = []; - return; - } - - if (this.#memberUnique === unique) { - return; - } - - this.#memberUnique = unique; - this.#getReferences(); - }, - 'umbReferencesDocumentUniqueObserver', - ); - } - - async #getReferences() { - if (!this.#memberUnique) { - throw new Error('Member unique is required'); - } - - this._loading = true; - - const { data } = await this.#referenceRepository.requestReferencedBy( - this.#memberUnique, - (this._currentPage - 1) * this.#itemsPerPage, - this.#itemsPerPage, - ); - - if (!data) return; - - this._total = data.total; - this._items = data.items; - - this._loading = false; - } - - #onPageChange(event: UUIPaginationEvent) { - if (this._currentPage === event.target.current) return; - this._currentPage = event.target.current; - - this.#getReferences(); - } - - override render() { - if (!this._items?.length) return nothing; - return html` - - ${when( - this._loading, - () => html``, - () => html`${this.#renderItems()} ${this.#renderPagination()}`, - )} - - `; - } - - #renderItems() { - if (!this._items) return; - return html` - - ${repeat( - this._items, - (item) => item.unique, - (item) => html``, - )} - - `; - } - - #renderPagination() { - if (!this._total) return nothing; - - const totalPages = Math.ceil(this._total / this.#itemsPerPage); - - if (totalPages <= 1) return nothing; - - return html` - - `; - } - - static override styles = [ - UmbTextStyles, - css` - :host { - display: contents; - } - - uui-table-cell { - color: var(--uui-color-text-alt); - } - - uui-pagination { - flex: 1; - display: inline-block; - } - - .pagination { - display: flex; - justify-content: center; - margin-top: var(--uui-size-space-4); - } - `, - ]; -} - -export default UmbMemberReferencesWorkspaceInfoAppElement; - -declare global { - interface HTMLElementTagNameMap { - 'umb-member-references-workspace-info-app': UmbMemberReferencesWorkspaceInfoAppElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/member-reference-response.management-api.mapping.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/member-reference-response.management-api.mapping.ts index e6418503e8..408774eca0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/member-reference-response.management-api.mapping.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/member-reference-response.management-api.mapping.ts @@ -12,9 +12,10 @@ export class UmbMemberReferenceResponseManagementApiDataMapping return { entityType: UMB_MEMBER_ENTITY_TYPE, memberType: { - alias: data.memberType.alias, - icon: data.memberType.icon, - name: data.memberType.name, + alias: data.memberType.alias!, + icon: data.memberType.icon!, + name: data.memberType.name!, + unique: data.memberType.id, }, name: data.name, // TODO: this is a hardcoded array until the server can return the correct variants array diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/types.ts index bc62433db5..c63aad90ce 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/reference/repository/types.ts @@ -1,6 +1,5 @@ import type { UmbMemberItemVariantModel } from '../../item/types.js'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; -import type { TrackedReferenceMemberTypeModel } from '@umbraco-cms/backoffice/external/backend-api'; export interface UmbMemberReferenceModel extends UmbEntityModel { /** @@ -9,6 +8,11 @@ export interface UmbMemberReferenceModel extends UmbEntityModel { * @memberof UmbMemberReferenceModel */ name?: string | null; - memberType: TrackedReferenceMemberTypeModel; + memberType: { + alias: string; + icon: string; + name: string; + unique: string; + }; variants: Array; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/manifests.ts index fceac92145..1d63998360 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/manifests.ts @@ -3,6 +3,7 @@ import { manifests as bulkTrashManifests } from './entity-actions/bulk-trash/man import { manifests as collectionManifests } from './collection/manifests.js'; import { manifests as deleteManifests } from './entity-actions/delete/manifests.js'; import { manifests as trashManifests } from './entity-actions/trash/manifests.js'; +import { manifests as workspaceInfoAppManifests } from './reference/workspace-info-app/manifests.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; export const manifests: Array = [ @@ -11,4 +12,5 @@ export const manifests: Array = ...collectionManifests, ...deleteManifests, ...trashManifests, + ...workspaceInfoAppManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/document-references-workspace-view-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts similarity index 52% rename from src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/document-references-workspace-view-info.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts index 61910918bb..89a1bbaf4d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/reference/info-app/document-references-workspace-view-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts @@ -1,14 +1,25 @@ -import { UmbDocumentReferenceRepository } from '../repository/index.js'; -import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from '../../constants.js'; -import { css, customElement, html, nothing, repeat, state } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbEntityReferenceRepository, UmbReferenceItemModel } from '../types.js'; +import type { ManifestWorkspaceInfoAppEntityReferencesKind } from './types.js'; +import { css, customElement, html, nothing, property, repeat, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { UmbReferenceItemModel } from '@umbraco-cms/backoffice/relations'; import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { UMB_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; +import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry'; + +@customElement('umb-entity-references-workspace-info-app') +export class UmbEntityReferencesWorkspaceInfoAppElement extends UmbLitElement { + @property({ type: Object }) + private _manifest?: ManifestWorkspaceInfoAppEntityReferencesKind | undefined; + public get manifest(): ManifestWorkspaceInfoAppEntityReferencesKind | undefined { + return this._manifest; + } + public set manifest(value: ManifestWorkspaceInfoAppEntityReferencesKind | undefined) { + this._manifest = value; + this.#init(); + } -@customElement('umb-document-references-workspace-info-app') -export class UmbDocumentReferencesWorkspaceInfoAppElement extends UmbLitElement { @state() private _currentPage = 1; @@ -19,47 +30,62 @@ export class UmbDocumentReferencesWorkspaceInfoAppElement extends UmbLitElement private _items?: Array = []; #itemsPerPage = 10; - #referenceRepository = new UmbDocumentReferenceRepository(this); - #documentUnique?: UmbEntityUnique; - #workspaceContext?: typeof UMB_DOCUMENT_WORKSPACE_CONTEXT.TYPE; + #referenceRepository?: UmbEntityReferenceRepository; + #unique?: UmbEntityUnique; + #workspaceContext?: typeof UMB_ENTITY_WORKSPACE_CONTEXT.TYPE; constructor() { super(); - this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (context) => { + this.consumeContext(UMB_ENTITY_WORKSPACE_CONTEXT, (context) => { this.#workspaceContext = context; - this.#observeDocumentUnique(); + this.#observeUnique(); }); } - #observeDocumentUnique() { + async #init() { + if (!this._manifest) return; + const referenceRepositoryAlias = this._manifest.meta.referenceRepositoryAlias; + + if (!referenceRepositoryAlias) { + throw new Error('Reference repository alias is required'); + } + + this.#referenceRepository = await createExtensionApiByAlias( + this, + referenceRepositoryAlias, + ); + + this.#getReferences(); + } + + #observeUnique() { this.observe( this.#workspaceContext?.unique, (unique) => { if (!unique) { - this.#documentUnique = undefined; + this.#unique = undefined; this._items = []; return; } - if (this.#documentUnique === unique) { + if (this.#unique === unique) { return; } - this.#documentUnique = unique; + this.#unique = unique; this.#getReferences(); }, - 'umbReferencesDocumentUniqueObserver', + 'umbEntityReferencesUniqueObserver', ); } async #getReferences() { - if (!this.#documentUnique) { - throw new Error('Document unique is required'); - } + if (!this.#unique) return; + if (!this.#referenceRepository) return; const { data } = await this.#referenceRepository.requestReferencedBy( - this.#documentUnique, + this.#unique, (this._currentPage - 1) * this.#itemsPerPage, this.#itemsPerPage, ); @@ -105,7 +131,7 @@ export class UmbDocumentReferencesWorkspaceInfoAppElement extends UmbLitElement if (totalPages <= 1) return nothing; return html` - ` @@ -425,11 +425,11 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper ? html` ${this._contentInvalid - ? html`!` + ? html`!` : nothing} ` : this._showContentEdit === false && this._exposed === false @@ -448,11 +448,11 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper ? html` ${this._settingsInvalid - ? html`!` + ? html`!` : nothing} ` : nothing} @@ -505,7 +505,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper :host([settings-invalid])::after, :host([content-invalid])::after { - border-color: var(--uui-color-danger); + border-color: var(--uui-color-invalid); } uui-action-bar { diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index 4708b444a3..a056c7045f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -270,7 +270,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert ${this.#renderEditAction()} ${this.#renderEditSettingsAction()} ${!this._showContentEdit && this._contentInvalid - ? html`!` + ? html`!` : nothing}
` @@ -291,11 +291,11 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert ? html` ${this._contentInvalid - ? html`!` + ? html`!` : nothing} ` : this._showContentEdit === false && this._exposed === false @@ -314,11 +314,11 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert ? html` ${this._settingsInvalid - ? html`!` + ? html`!` : nothing} ` : nothing} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-number-range/input-number-range.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-number-range/input-number-range.element.ts index 86315f5ce8..d9a961b5aa 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-number-range/input-number-range.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-number-range/input-number-range.element.ts @@ -131,10 +131,10 @@ export class UmbInputNumberRangeElement extends UmbFormControlMixin(UmbLitElemen static override styles = css` :host(:invalid:not([pristine])) { - color: var(--uui-color-danger); + color: var(--uui-color-invalid); } :host(:invalid:not([pristine])) uui-input { - border-color: var(--uui-color-danger); + border-color: var(--uui-color-invalid); } `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts index c854fb9dea..fc9ae5ba82 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-with-alias/input-with-alias.element.ts @@ -147,10 +147,10 @@ export class UmbInputWithAliasElement extends UmbFormControlMixin uui-input { - border-color: var(--uui-color-danger); + border-color: var(--uui-color-invalid); } `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts index e16d1f8210..47d05e9cbe 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts @@ -188,7 +188,6 @@ export class UmbMultipleColorPickerItemInputElement extends UUIFormControlMixin( () => html` html` ; contentValidationRepository?: ClassConstructor>; skipValidationOnSubmit?: boolean; + ignoreValidationResultOnSubmit?: boolean; contentVariantScaffold: VariantModelType; contentTypePropertyName: string; saveModalToken?: UmbModalToken, UmbContentVariantPickerValue>; @@ -151,6 +152,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< } #validateOnSubmit: boolean; + #ignoreValidationResultOnSubmit: boolean; #serverValidation = new UmbServerModelValidatorContext(this); #validationRepositoryClass?: ClassConstructor>; #validationRepository?: UmbContentValidationRepository; @@ -178,6 +180,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< const contentTypeDetailRepository = new args.contentTypeDetailRepository(this); this.#validationRepositoryClass = args.contentValidationRepository; this.#validateOnSubmit = args.skipValidationOnSubmit ? !args.skipValidationOnSubmit : true; + this.#ignoreValidationResultOnSubmit = args.ignoreValidationResultOnSubmit ?? false; this.structure = new UmbContentTypeStructureManager(this, contentTypeDetailRepository); this.variesByCulture = this.structure.ownerContentTypeObservablePart((x) => x?.variesByCulture); this.variesBySegment = this.structure.ownerContentTypeObservablePart((x) => x?.variesBySegment); @@ -727,7 +730,11 @@ export abstract class UmbContentDetailWorkspaceContextBase< return this.performCreateOrUpdate(variantIds, saveData); }, async (reason?: any) => { - return this.invalidSubmit(reason); + if (this.#ignoreValidationResultOnSubmit) { + return this.performCreateOrUpdate(variantIds, saveData); + } else { + return this.invalidSubmit(reason); + } }, ); } else { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.element.ts index e7e1b41dc5..fac3aa45bc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property-layout/property-layout.element.ts @@ -74,7 +74,10 @@ export class UmbPropertyLayoutElement extends UmbLitElement {
${this.localize.string(this.label)} - ${when(this.invalid, () => html`!`)} + ${when( + this.invalid, + () => html`
!
`, + )}
${this.#renderDescription()} @@ -129,15 +132,28 @@ export class UmbPropertyLayoutElement extends UmbLitElement { } /*}*/ + :host { + /* TODO: Temp solution to not get a yellow asterisk when invalid. */ + --umb-temp-uui-color-invalid: var(--uui-color-invalid); + } + #label { position: relative; word-break: break-word; + /* TODO: Temp solution to not get a yellow asterisk when invalid. */ + --uui-color-invalid: var(--uui-color-danger); } - :host([invalid]) #label { - color: var(--uui-color-danger); + #invalid-badge { + display: inline-block; + position: relative; + width: 18px; + height: 1em; + margin-right: 6px; } uui-badge { - right: -30px; + //height: var(--uui-color-invalid); + background-color: var(--umb-temp-uui-color-invalid); + color: var(--uui-color-invalid-contrast); } #description { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/components/form-validation-message.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/components/form-validation-message.element.ts index 38a4fb1181..e8b1806b50 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/components/form-validation-message.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/components/form-validation-message.element.ts @@ -85,7 +85,7 @@ export class UmbFormValidationMessageElement extends UmbLitElement { static override styles = [ css` #messages { - color: var(--uui-color-danger-standalone); + color: var(--uui-color-invalid-standalone); } `, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.element.ts index 40fd96f0ef..873e5c3087 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.element.ts @@ -7,7 +7,22 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @customElement('umb-workspace-modal') export class UmbWorkspaceModalElement extends UmbLitElement { @property({ attribute: false }) - data?: UmbWorkspaceModalData; + public get data(): UmbWorkspaceModalData | undefined { + return this._data; + } + public set data(value: UmbWorkspaceModalData | undefined) { + this._data = value; + if (value?.inheritValidationLook) { + // Do nothing. + } else { + const elementStyle = this.style; + elementStyle.setProperty('--uui-color-invalid', 'var(--uui-color-danger)'); + elementStyle.setProperty('--uui-color-invalid-emphasis', 'var(--uui-color-danger-emphasis)'); + elementStyle.setProperty('--uui-color-invalid-standalone', 'var(--uui-color-danger-standalone)'); + elementStyle.setProperty('--uui-color-invalid-contrast', 'var(--uui-color-danger-contrast)'); + } + } + private _data?: UmbWorkspaceModalData | undefined; /** * TODO: Consider if this binding and events integration is the right for communicating back the modal handler. Or if we should go with some Context API. like a Modal Context API. diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.token.ts index b78617a648..c1ee724432 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/modals/workspace-modal.token.ts @@ -3,6 +3,7 @@ export interface UmbWorkspaceModalData { entityType: string; preset: Partial; baseDataPath?: string; + inheritValidationLook?: boolean; } export type UmbWorkspaceModalValue = diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts index 5f98391fa7..2f2b69fb85 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts @@ -115,9 +115,9 @@ export class UmbInputDataTypeElement extends UmbFormControlMixin(UmbLitElement, --uui-button-padding-bottom-factor: 4; } :host(:invalid:not([pristine])) #empty-state-button { - --uui-button-border-color: var(--uui-color-danger); + --uui-button-border-color: var(--uui-color-invalid); --uui-button-border-width: 2px; - --uui-button-contrast: var(--uui-color-danger); + --uui-button-contrast: var(--uui-color-invalid); } `, ]; 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 5d67883062..fee74f7c74 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 @@ -72,6 +72,11 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase { + const elementStyle = (this.getHostElement() as HTMLElement).style; + elementStyle.removeProperty('--uui-color-invalid'); + elementStyle.removeProperty('--uui-color-invalid-emphasis'); + elementStyle.removeProperty('--uui-color-invalid-standalone'); + elementStyle.removeProperty('--uui-color-invalid-contrast'); return this.#handleSaveAndPublish(); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts index baeeaddab3..62cac87fb9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts @@ -80,7 +80,8 @@ export class UmbDocumentWorkspaceContext detailRepositoryAlias: UMB_DOCUMENT_DETAIL_REPOSITORY_ALIAS, contentTypeDetailRepository: UmbDocumentTypeDetailRepository, contentValidationRepository: UmbDocumentValidationRepository, - skipValidationOnSubmit: true, + skipValidationOnSubmit: false, + ignoreValidationResultOnSubmit: true, contentVariantScaffold: UMB_DOCUMENT_DETAIL_MODEL_VARIANT_SCAFFOLD, contentTypePropertyName: 'documentType', saveModalToken: UMB_DOCUMENT_SAVE_MODAL, @@ -270,11 +271,11 @@ export class UmbDocumentWorkspaceContext * @returns {Promise} a promise which resolves once it has been completed. */ public override requestSubmit() { - return this._handleSubmit(); - } - - // Because we do not make validation prevent submission this also submits the workspace. [NL] - public override invalidSubmit() { + const elementStyle = (this.getHostElement() as HTMLElement).style; + elementStyle.setProperty('--uui-color-invalid', 'var(--uui-color-warning)'); + elementStyle.setProperty('--uui-color-invalid-emphasis', 'var(--uui-color-warning-emphasis)'); + elementStyle.setProperty('--uui-color-invalid-standalone', 'var(--uui-color-warning-standalone)'); + elementStyle.setProperty('--uui-color-invalid-contrast', 'var(--uui-color-warning-contrast)'); return this._handleSubmit(); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts index 00060a0a99..3292e55b0c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts @@ -180,7 +180,7 @@ export class UmbPropertyEditorUIDropdownElement } .error { - color: var(--uui-color-danger); + color: var(--uui-color-invalid); font-size: var(--uui-font-size-small); } `, diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts index 12078ec55a..1057f9099c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts @@ -240,9 +240,9 @@ export class UmbInputTiptapElement extends UmbFormControlMixin Date: Wed, 9 Apr 2025 13:47:25 +0200 Subject: [PATCH 52/53] Display content type names on dynamic node query steps (#18742) * Display content type names on dynamic node query steps. * Refactored to use `UmbRepositoryItemsManager` observable --------- Co-authored-by: leekelleher --- ...ut-content-picker-document-root.element.ts | 75 ++++++++++++++++--- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/components/input-content-picker-document-root.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/components/input-content-picker-document-root.element.ts index 901b185141..21df4ede50 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/components/input-content-picker-document-root.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/components/input-content-picker-document-root.element.ts @@ -4,15 +4,20 @@ import { UMB_CONTENT_PICKER_DOCUMENT_ROOT_QUERY_STEP_PICKER_MODAL, } from '../modals/index.js'; import type { ManifestDynamicRootOrigin, ManifestDynamicRootQueryStep } from '../dynamic-root.extension.js'; -import { html, css, customElement, property, ifDefined, state, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, ifDefined, property, repeat, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; import { UmbId } from '@umbraco-cms/backoffice/id'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbRepositoryItemsManager } from '@umbraco-cms/backoffice/repository'; import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UMB_DOCUMENT_ITEM_REPOSITORY_ALIAS } from '@umbraco-cms/backoffice/document'; +import { UMB_DOCUMENT_TYPE_ITEM_REPOSITORY_ALIAS } from '@umbraco-cms/backoffice/document-type'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import type { UmbDocumentItemModel } from '@umbraco-cms/backoffice/document'; +import type { UmbDocumentTypeItemModel } from '@umbraco-cms/backoffice/document-type'; import type { UmbModalContext } from '@umbraco-cms/backoffice/modal'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; const elementName = 'umb-input-content-picker-document-root'; @customElement(elementName) @@ -20,6 +25,16 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi string | undefined, typeof UmbLitElement >(UmbLitElement) { + readonly #documentItemManager = new UmbRepositoryItemsManager( + this, + UMB_DOCUMENT_ITEM_REPOSITORY_ALIAS, + ); + + readonly #documentTypeItemManager = new UmbRepositoryItemsManager( + this, + UMB_DOCUMENT_TYPE_ITEM_REPOSITORY_ALIAS, + ); + protected override getFormElement() { return undefined; } @@ -35,6 +50,10 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi #dynamicRootOrigin?: { label: string; icon: string; description?: string }; + #documentLookup: Record = {}; + + #documentTypeLookup: Record = {}; + #modalContext?: typeof UMB_MODAL_MANAGER_CONTEXT.TYPE; #openModal?: UmbModalContext; @@ -59,9 +78,29 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi this._queryStepManifests = queryStepManifests; }, ); + + this.observe(this.#documentItemManager.items, (documents) => { + if (!documents?.length) return; + + documents.forEach((document) => { + this.#documentLookup[document.unique] = document.name; + }); + + this.requestUpdate(); + }); + + this.observe(this.#documentTypeItemManager.items, (documentTypes) => { + if (!documentTypes?.length) return; + + documentTypes.forEach((documentType) => { + this.#documentTypeLookup[documentType.unique] = documentType.name; + }); + + this.requestUpdate(); + }); } - override connectedCallback(): void { + override connectedCallback() { super.connectedCallback(); this.#updateDynamicRootOrigin(this.data); @@ -116,6 +155,11 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi #updateDynamicRootOrigin(data?: UmbContentPickerDynamicRoot) { if (!data) return; const origin = this._originManifests.find((item) => item.meta.originAlias === data.originAlias)?.meta; + + if (data.originKey) { + this.#documentItemManager.setUniques([data.originKey]); + } + this.#dynamicRootOrigin = { label: origin?.label ?? data.originAlias, icon: origin?.icon ?? 'icon-wand', @@ -131,7 +175,10 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi querySteps = querySteps.map((item) => (item.unique ? item : { ...item, unique: UmbId.new() })); } + this.#documentTypeItemManager.setUniques((querySteps ?? []).map((x) => x.anyOfDocTypeKeys ?? []).flat()); + this.#sorter?.setModel(querySteps ?? []); + this.data = { ...this.data, ...{ querySteps } }; } @@ -142,8 +189,16 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi description?: string; } { const step = this._queryStepManifests.find((step) => step.meta.queryStepAlias === item.alias)?.meta; - const docTypes = item.anyOfDocTypeKeys?.join(', '); - const description = docTypes ? this.localize.term('dynamicRoot_queryStepTypes') + docTypes : undefined; + + const docTypeNames = + item.anyOfDocTypeKeys + ?.map((docTypeKey) => this.#documentTypeLookup[docTypeKey] ?? docTypeKey) + .sort() + .join(', ') ?? ''; + + const description = item.anyOfDocTypeKeys + ? this.localize.term('dynamicRoot_queryStepTypes') + docTypeNames + : undefined; return { unique: item.unique, @@ -193,11 +248,11 @@ export class UmbInputContentPickerDocumentRootElement extends UmbFormControlMixi #renderOrigin() { if (!this.#dynamicRootOrigin) return; + const description = this.#dynamicRootOrigin.description + ? this.#documentLookup[this.#dynamicRootOrigin.description] + : ''; return html` - + Date: Wed, 9 Apr 2025 13:14:31 +0000 Subject: [PATCH 53/53] Bump koa from 2.15.3 to 2.16.1 in /src/Umbraco.Web.UI.Client Bumps [koa](https://github.com/koajs/koa) from 2.15.3 to 2.16.1. - [Release notes](https://github.com/koajs/koa/releases) - [Changelog](https://github.com/koajs/koa/blob/master/History.md) - [Commits](https://github.com/koajs/koa/compare/2.15.3...v2.16.1) --- updated-dependencies: - dependency-name: koa dependency-version: 2.16.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- src/Umbraco.Web.UI.Client/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 1bd936cecb..0a3f5b2d17 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -10881,9 +10881,9 @@ } }, "node_modules/koa": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", - "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.1.tgz", + "integrity": "sha512-umfX9d3iuSxTQP4pnzLOz0HKnPg0FaUUIKcye2lOiz3KPu1Y3M3xlz76dISdFPQs37P9eJz1wUpcTS6KDPn9fA==", "dev": true, "license": "MIT", "dependencies": {