From 6658a521b29e8b9450e147b990afd2e2da0d5a67 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 31 Oct 2023 11:38:24 +0100 Subject: [PATCH] Dynamic Root (Alternative to XPath in MNTP) (#15035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Temp commit.. Initial work on XPath alternative for dymamically finding start nodes * First commit that goes all the way from ui to db for NearestAncestorOrSelf * Added more filters + return null from controller instead of not found * Bugfix * rewrite query to make sqlserver happy? * Added more tests * clean up initial step * Added tests and refactor * Update endpoint to take model instead of json * pick origin * Use model for config instead of string * append add filter button * fix * default filter * rename json fields * correct field names * minor corrections * Renaming.. * Rename endpoint * initial work for appending query steps * query steps ui * more localization * query step UI * Use doc type keys instead of alias * only for Documents * change to send keys to anyOfDocTypeKeys * Fix potential bug * Fix when level is impossible to get * correct prop to dynamicRoot * noValidStartNode dialog * custom query step * Renaming * Rollback unintended file change * Fixed issue if no doc type is chosen * Remove unintended file changes * More unintended changes * Renaming * Optimizations - IDE Recommendation for better source - Renaming for better clarity - Improving spacing/formatting - Typo corrections - Remove warnings concerning IEnumerable * Fix failed attempt bug --------- Co-authored-by: Niels Lyngsø Co-authored-by: Sven Geusens --- .../Services/SqliteSyntaxProvider.cs | 3 + .../UmbracoBuilder.Collections.cs | 22 + .../DependencyInjection/UmbracoBuilder.cs | 2 + .../DynamicRoot/DynamicRootContext.cs | 8 + .../DynamicRoot/DynamicRootNodeQuery.cs | 17 + .../DynamicRoot/DynamicRootService.cs | 74 ++ .../DynamicRoot/IDynamicRootService.cs | 9 + .../Origin/ByKeyDynamicRootOriginFinder.cs | 38 + .../Origin/CurrentDynamicRootOriginFinder.cs | 21 + .../DynamicRootOriginCollectionBuilder.cs | 8 + .../DynamicRootOriginFinderCollection.cs | 11 + .../Origin/IDynamicRootOriginFinder.cs | 9 + .../Origin/ParentDynamicRootOriginFinder.cs | 20 + .../Origin/RootDynamicRootOriginFinder.cs | 70 ++ .../Origin/SiteDynamicRootOriginFinder.cs | 55 ++ .../QuerySteps/DynamicRootQueryStep.cs | 11 + .../DynamicRootQueryStepCollection.cs | 11 + .../DynamicRootQueryStepCollectionBuilder.cs | 8 + ...thestAncestorOrSelfDynamicRootQueryStep.cs | 37 + ...estDescendantOrSelfDynamicRootQueryStep.cs | 35 + .../QuerySteps/IDynamicRootQueryStep.cs | 8 + .../QuerySteps/IDynamicRootRepository.cs | 12 + ...arestAncestorOrSelfDynamicRootQueryStep.cs | 37 + ...estDescendantOrSelfDynamicRootQueryStep.cs | 35 + .../EmbeddedResources/Lang/da.xml | 35 + .../EmbeddedResources/Lang/en.xml | 35 + .../EmbeddedResources/Lang/en_us.xml | 37 + .../Extensions/CollectionExtensions.cs | 8 + .../Extensions/EnumerableExtensions.cs | 1 + .../MultiNodePickerConfigurationTreeSource.cs | 27 + .../UmbracoBuilder.Repositories.cs | 3 + .../Logging/Serilog/LoggerConfigExtensions.cs | 2 +- .../Implement/DynamicRootRepository.cs | 125 +++ .../SqlSyntax/ISqlSyntaxProvider.cs | 4 + .../SqlSyntax/SqlSyntaxProviderBase.cs | 3 + .../Controllers/EntityController.cs | 107 ++- .../common/filters/nestedcontent.filter.js | 3 + .../src/common/resources/entity.resource.js | 14 + .../src/less/components/umb-node-preview.less | 7 +- .../pickdynamicrootcustomstep.controller.js | 40 + .../pickdynamicrootcustomstep.html | 55 ++ .../pickdynamicrootorigin.controller.js | 82 ++ .../pickdynamicrootorigin.html | 58 ++ .../pickdynamicrootquerystep.controller.js | 91 +++ .../pickdynamicrootquerystep.html | 58 ++ .../prevalueeditors/treesource.controller.js | 236 ++++-- .../src/views/prevalueeditors/treesource.html | 117 ++- .../contentpicker/contentpicker.controller.js | 44 +- .../Services/DynamicRootServiceTests.cs | 719 ++++++++++++++++++ 49 files changed, 2387 insertions(+), 85 deletions(-) create mode 100644 src/Umbraco.Core/DynamicRoot/DynamicRootContext.cs create mode 100644 src/Umbraco.Core/DynamicRoot/DynamicRootNodeQuery.cs create mode 100644 src/Umbraco.Core/DynamicRoot/DynamicRootService.cs create mode 100644 src/Umbraco.Core/DynamicRoot/IDynamicRootService.cs create mode 100644 src/Umbraco.Core/DynamicRoot/Origin/ByKeyDynamicRootOriginFinder.cs create mode 100644 src/Umbraco.Core/DynamicRoot/Origin/CurrentDynamicRootOriginFinder.cs create mode 100644 src/Umbraco.Core/DynamicRoot/Origin/DynamicRootOriginCollectionBuilder.cs create mode 100644 src/Umbraco.Core/DynamicRoot/Origin/DynamicRootOriginFinderCollection.cs create mode 100644 src/Umbraco.Core/DynamicRoot/Origin/IDynamicRootOriginFinder.cs create mode 100644 src/Umbraco.Core/DynamicRoot/Origin/ParentDynamicRootOriginFinder.cs create mode 100644 src/Umbraco.Core/DynamicRoot/Origin/RootDynamicRootOriginFinder.cs create mode 100644 src/Umbraco.Core/DynamicRoot/Origin/SiteDynamicRootOriginFinder.cs create mode 100644 src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStep.cs create mode 100644 src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStepCollection.cs create mode 100644 src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStepCollectionBuilder.cs create mode 100644 src/Umbraco.Core/DynamicRoot/QuerySteps/FarthestAncestorOrSelfDynamicRootQueryStep.cs create mode 100644 src/Umbraco.Core/DynamicRoot/QuerySteps/FarthestDescendantOrSelfDynamicRootQueryStep.cs create mode 100644 src/Umbraco.Core/DynamicRoot/QuerySteps/IDynamicRootQueryStep.cs create mode 100644 src/Umbraco.Core/DynamicRoot/QuerySteps/IDynamicRootRepository.cs create mode 100644 src/Umbraco.Core/DynamicRoot/QuerySteps/NearestAncestorOrSelfDynamicRootQueryStep.cs create mode 100644 src/Umbraco.Core/DynamicRoot/QuerySteps/NearestDescendantOrSelfDynamicRootQueryStep.cs create mode 100644 src/Umbraco.Core/Extensions/CollectionExtensions.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DynamicRootRepository.cs create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootcustomstep/pickdynamicrootcustomstep.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootcustomstep/pickdynamicrootcustomstep.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootorigin/pickdynamicrootorigin.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootorigin/pickdynamicrootorigin.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootquerystep/pickdynamicrootquerystep.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootquerystep/pickdynamicrootquerystep.html create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DynamicRootServiceTests.cs diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs index af9147b29a..4082b828e9 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs +++ b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs @@ -468,4 +468,7 @@ public class SqliteSyntaxProvider : SqlSyntaxProviderBase public bool IsUnique { get; set; } } + + public override string Length => "length"; + public override string Substring => "substr"; } diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs index e6b413b07f..a34d4e377c 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs @@ -15,6 +15,8 @@ using Umbraco.Cms.Core.PropertyEditors.Validators; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Sections; using Umbraco.Cms.Core.Snippets; +using Umbraco.Cms.Core.DynamicRoot.QuerySteps; +using Umbraco.Cms.Core.DynamicRoot.Origin; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Tour; using Umbraco.Cms.Core.Trees; @@ -78,6 +80,20 @@ public static partial class UmbracoBuilderExtensions .Append() .Append() .Append(); + + builder.DynamicRootOriginFinders() + .Append() + .Append() + .Append() + .Append() + .Append(); + + builder.DynamicRootSteps() + .Append() + .Append() + .Append() + .Append(); + builder.Components(); // register core CMS dashboards and 3rd party types - will be ordered by weight attribute & merged with package.manifest dashboards builder.Dashboards() @@ -197,6 +213,12 @@ public static partial class UmbracoBuilderExtensions public static SectionCollectionBuilder Sections(this IUmbracoBuilder builder) => builder.WithCollectionBuilder(); + public static DynamicRootOriginFinderCollectionBuilder DynamicRootOriginFinders(this IUmbracoBuilder builder) + => builder.WithCollectionBuilder(); + + public static DynamicRootQueryStepCollectionBuilder DynamicRootSteps(this IUmbracoBuilder builder) + => builder.WithCollectionBuilder(); + /// /// Gets the backoffice sections/applications collection builder. /// diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 9e43c1eb35..0e132d3fed 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -36,6 +36,7 @@ using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Snippets; +using Umbraco.Cms.Core.DynamicRoot; using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Core.Telemetry; using Umbraco.Cms.Core.Templates; @@ -330,6 +331,7 @@ namespace Umbraco.Cms.Core.DependencyInjection // Register filestream security analyzers Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/DynamicRoot/DynamicRootContext.cs b/src/Umbraco.Core/DynamicRoot/DynamicRootContext.cs new file mode 100644 index 0000000000..755c8a3c71 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/DynamicRootContext.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.DynamicRoot; + +public struct DynamicRootContext +{ + public required Guid? CurrentKey { get; set; } + + public required Guid ParentKey { get; set; } +} diff --git a/src/Umbraco.Core/DynamicRoot/DynamicRootNodeQuery.cs b/src/Umbraco.Core/DynamicRoot/DynamicRootNodeQuery.cs new file mode 100644 index 0000000000..08371e8dae --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/DynamicRootNodeQuery.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.DynamicRoot.QuerySteps; + +namespace Umbraco.Cms.Core.DynamicRoot; + +/// +/// Specifies origin and context data with optional query steps to find Dynamic Roots +/// +public class DynamicRootNodeQuery +{ + public required string OriginAlias { get; set; } + + public Guid? OriginKey { get; set; } + + public required DynamicRootContext Context { get; set; } + + public IEnumerable QuerySteps { get; set; } = Array.Empty(); +} diff --git a/src/Umbraco.Core/DynamicRoot/DynamicRootService.cs b/src/Umbraco.Core/DynamicRoot/DynamicRootService.cs new file mode 100644 index 0000000000..23b8f75878 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/DynamicRootService.cs @@ -0,0 +1,74 @@ +using Umbraco.Cms.Core.DynamicRoot.QuerySteps; +using Umbraco.Cms.Core.DynamicRoot.Origin; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.DynamicRoot; + +public class DynamicRootService : IDynamicRootService +{ + private readonly DynamicRootOriginFinderCollection _originFinderCollection; + private readonly DynamicRootQueryStepCollection _queryStepCollection; + + public DynamicRootService(DynamicRootOriginFinderCollection originFinderCollection, DynamicRootQueryStepCollection queryStepCollection) + { + _originFinderCollection = originFinderCollection; + _queryStepCollection = queryStepCollection; + } + + public async Task> GetDynamicRootsAsync(DynamicRootNodeQuery dynamicRootNodeQuery) + { + var originKey = FindOriginKey(dynamicRootNodeQuery); + + if (originKey is null) + { + return Array.Empty(); + } + + // no steps means the origin is the root + if (dynamicRootNodeQuery.QuerySteps.Any() is false) + { + return originKey.Value.Yield(); + } + + // start with the origin + ICollection filtered = new []{originKey.Value}; + + // resolved each Query Step using the result of the previous step (or origin) + foreach (DynamicRootQueryStep startNodeSelectorFilter in dynamicRootNodeQuery.QuerySteps) + { + filtered = await ExcuteFiltersAsync(filtered, startNodeSelectorFilter); + } + + return filtered; + } + + internal async Task> ExcuteFiltersAsync(ICollection origin, DynamicRootQueryStep dynamicRootQueryStep) + { + foreach (IDynamicRootQueryStep queryStep in _queryStepCollection) + { + var queryStepAttempt = await queryStep.ExecuteAsync(origin, dynamicRootQueryStep); + if (queryStepAttempt is { Success: true, Result: not null }) + { + return queryStepAttempt.Result; + } + } + + throw new NotSupportedException($"Did not find any filteres that could handle {dynamicRootQueryStep.Alias}"); + } + + internal Guid? FindOriginKey(DynamicRootNodeQuery dynamicRootNodeQuery) + { + foreach (IDynamicRootOriginFinder originFinder in _originFinderCollection) + { + Guid? originKey = originFinder.FindOriginKey(dynamicRootNodeQuery); + + if (originKey is not null) + { + return originKey; + } + } + + return null; + } +} + diff --git a/src/Umbraco.Core/DynamicRoot/IDynamicRootService.cs b/src/Umbraco.Core/DynamicRoot/IDynamicRootService.cs new file mode 100644 index 0000000000..226b5d9f82 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/IDynamicRootService.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.DynamicRoot; + +/// +/// Supports finding content roots for pickers (like MNTP) in a dynamic fashion +/// +public interface IDynamicRootService +{ + Task> GetDynamicRootsAsync(DynamicRootNodeQuery dynamicRootNodeQuery); +} diff --git a/src/Umbraco.Core/DynamicRoot/Origin/ByKeyDynamicRootOriginFinder.cs b/src/Umbraco.Core/DynamicRoot/Origin/ByKeyDynamicRootOriginFinder.cs new file mode 100644 index 0000000000..f720d08e1b --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/Origin/ByKeyDynamicRootOriginFinder.cs @@ -0,0 +1,38 @@ +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.DynamicRoot.Origin; + +public class ByKeyDynamicRootOriginFinder : IDynamicRootOriginFinder +{ + protected virtual string SupportedOriginType { get; set; } = "ByKey"; + + private readonly IEntityService _entityService; + + private ISet _allowedObjectTypes = new HashSet(new[] + { + Constants.ObjectTypes.Document, Constants.ObjectTypes.SystemRoot + }); + + public ByKeyDynamicRootOriginFinder(IEntityService entityService) + { + _entityService = entityService; + } + + public virtual Guid? FindOriginKey(DynamicRootNodeQuery query) + { + if (query.OriginAlias != SupportedOriginType || query.OriginKey is null) + { + return null; + } + + IEntitySlim? entity = _entityService.Get(query.OriginKey.Value); + + if (entity is null || _allowedObjectTypes.Contains(entity.NodeObjectType) is false) + { + return null; + } + + return entity.Key; + } +} diff --git a/src/Umbraco.Core/DynamicRoot/Origin/CurrentDynamicRootOriginFinder.cs b/src/Umbraco.Core/DynamicRoot/Origin/CurrentDynamicRootOriginFinder.cs new file mode 100644 index 0000000000..cb4bd6d26e --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/Origin/CurrentDynamicRootOriginFinder.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.DynamicRoot.Origin; + +public class CurrentDynamicRootOriginFinder : ByKeyDynamicRootOriginFinder +{ + public CurrentDynamicRootOriginFinder(IEntityService entityService) + : base(entityService) + { + } + + protected override string SupportedOriginType { get; set; } = "Current"; + + public override Guid? FindOriginKey(DynamicRootNodeQuery query) + { + query.OriginKey = query.Context.CurrentKey; + var baseResult = base.FindOriginKey(query); + + return baseResult; + } +} diff --git a/src/Umbraco.Core/DynamicRoot/Origin/DynamicRootOriginCollectionBuilder.cs b/src/Umbraco.Core/DynamicRoot/Origin/DynamicRootOriginCollectionBuilder.cs new file mode 100644 index 0000000000..1ee1693bb1 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/Origin/DynamicRootOriginCollectionBuilder.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DynamicRoot.Origin; + +public class DynamicRootOriginFinderCollectionBuilder : OrderedCollectionBuilderBase +{ + protected override DynamicRootOriginFinderCollectionBuilder This => this; +} diff --git a/src/Umbraco.Core/DynamicRoot/Origin/DynamicRootOriginFinderCollection.cs b/src/Umbraco.Core/DynamicRoot/Origin/DynamicRootOriginFinderCollection.cs new file mode 100644 index 0000000000..1d137ca924 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/Origin/DynamicRootOriginFinderCollection.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DynamicRoot.Origin; + +public class DynamicRootOriginFinderCollection : BuilderCollectionBase +{ + public DynamicRootOriginFinderCollection(Func> items) + : base(items) + { + } +} diff --git a/src/Umbraco.Core/DynamicRoot/Origin/IDynamicRootOriginFinder.cs b/src/Umbraco.Core/DynamicRoot/Origin/IDynamicRootOriginFinder.cs new file mode 100644 index 0000000000..838f1822f7 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/Origin/IDynamicRootOriginFinder.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.DynamicRoot.Origin; + +/// +/// Supports finding the Origin For a given query +/// +public interface IDynamicRootOriginFinder +{ + Guid? FindOriginKey(DynamicRootNodeQuery dynamicRootNodeQuery); +} diff --git a/src/Umbraco.Core/DynamicRoot/Origin/ParentDynamicRootOriginFinder.cs b/src/Umbraco.Core/DynamicRoot/Origin/ParentDynamicRootOriginFinder.cs new file mode 100644 index 0000000000..3ab6f4e71f --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/Origin/ParentDynamicRootOriginFinder.cs @@ -0,0 +1,20 @@ +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.DynamicRoot.Origin; + +public class ParentDynamicRootOriginFinder : ByKeyDynamicRootOriginFinder +{ + public ParentDynamicRootOriginFinder(IEntityService entityService) : base(entityService) + { + } + + protected override string SupportedOriginType { get; set; } = "Parent"; + + public override Guid? FindOriginKey(DynamicRootNodeQuery query) + { + query.OriginKey = query.Context.ParentKey; + var baseResult = base.FindOriginKey(query); + + return baseResult; + } +} diff --git a/src/Umbraco.Core/DynamicRoot/Origin/RootDynamicRootOriginFinder.cs b/src/Umbraco.Core/DynamicRoot/Origin/RootDynamicRootOriginFinder.cs new file mode 100644 index 0000000000..44766fb2dc --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/Origin/RootDynamicRootOriginFinder.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.DynamicRoot.Origin; + +public class RootDynamicRootOriginFinder : IDynamicRootOriginFinder +{ + private readonly IEntityService _entityService; + + public RootDynamicRootOriginFinder(IEntityService entityService) + { + _entityService = entityService; + } + + private ISet _allowedObjectTypes = new HashSet(new[] + { + Constants.ObjectTypes.Document, Constants.ObjectTypes.SystemRoot + }); + + protected virtual string SupportedOriginType { get; set; } = "Root"; + + public virtual Guid? FindOriginKey(DynamicRootNodeQuery query) + { + if (query.OriginAlias != SupportedOriginType) + { + return null; + } + + var entity = _entityService.Get(query.Context.ParentKey); + + if (entity is null || _allowedObjectTypes.Contains(entity.NodeObjectType) is false) + { + return null; + } + + var path = entity.Path.Split(","); + if (path.Length < 2) + { + return null; + } + + + var rootId = GetRootId(path); + IEntitySlim? root = rootId is null ? null : _entityService.Get(rootId.Value); + + if (root is null + || root.NodeObjectType != Constants.ObjectTypes.Document) + { + return null; + } + + return root.Key; + } + + private static int? GetRootId(string[] path) + { + foreach (var contentId in path) + { + if (contentId is Constants.System.RootString or Constants.System.RecycleBinContentString) + { + continue; + } + + return int.Parse(contentId, NumberStyles.Integer, CultureInfo.InvariantCulture); + } + + return null; + } +} diff --git a/src/Umbraco.Core/DynamicRoot/Origin/SiteDynamicRootOriginFinder.cs b/src/Umbraco.Core/DynamicRoot/Origin/SiteDynamicRootOriginFinder.cs new file mode 100644 index 0000000000..d1e515de59 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/Origin/SiteDynamicRootOriginFinder.cs @@ -0,0 +1,55 @@ +using System.Globalization; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.DynamicRoot.Origin; + +public class SiteDynamicRootOriginFinder : RootDynamicRootOriginFinder +{ + private readonly IEntityService _entityService; + private readonly IDomainService _domainService; + + public SiteDynamicRootOriginFinder(IEntityService entityService, IDomainService domainService) : base(entityService) + { + _entityService = entityService; + _domainService = domainService; + } + + protected override string SupportedOriginType { get; set; } = "Site"; + + public override Guid? FindOriginKey(DynamicRootNodeQuery query) + { + if (query.OriginAlias != SupportedOriginType || query.Context.CurrentKey.HasValue is false) + { + return null; + } + + IEntitySlim? entity = _entityService.Get(query.Context.CurrentKey.Value); + if (entity is null || entity.NodeObjectType != Constants.ObjectTypes.Document) + { + return null; + } + + + IEnumerable reversePath = entity.Path.Split(",").Reverse(); + foreach (var contentIdString in reversePath) + { + var contentId = int.Parse(contentIdString, NumberStyles.Integer, CultureInfo.InvariantCulture); + IEnumerable domains = _domainService.GetAssignedDomains(contentId, true); + if (!domains.Any()) + { + continue; + } + + IEntitySlim? entityWithDomain = _entityService.Get(contentId); + if (entityWithDomain is not null) + { + return entityWithDomain.Key; + } + } + + // No domains assigned, we fall back to root. + return base.FindOriginKey(query); + } +} diff --git a/src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStep.cs b/src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStep.cs new file mode 100644 index 0000000000..be4d7ae030 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStep.cs @@ -0,0 +1,11 @@ +namespace Umbraco.Cms.Core.DynamicRoot.QuerySteps; + +public class DynamicRootQueryStep +{ + /// + /// Empty means all Doctypes + /// + public IEnumerable AnyOfDocTypeKeys { get; set; } = Array.Empty(); + + public string Alias { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStepCollection.cs b/src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStepCollection.cs new file mode 100644 index 0000000000..ba084025da --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStepCollection.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DynamicRoot.QuerySteps; + +public class DynamicRootQueryStepCollection : BuilderCollectionBase +{ + public DynamicRootQueryStepCollection(Func> items) + : base(items) + { + } +} diff --git a/src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStepCollectionBuilder.cs b/src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStepCollectionBuilder.cs new file mode 100644 index 0000000000..b10f4ea2e2 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/QuerySteps/DynamicRootQueryStepCollectionBuilder.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DynamicRoot.QuerySteps; + +public class DynamicRootQueryStepCollectionBuilder : OrderedCollectionBuilderBase +{ + protected override DynamicRootQueryStepCollectionBuilder This => this; +} diff --git a/src/Umbraco.Core/DynamicRoot/QuerySteps/FarthestAncestorOrSelfDynamicRootQueryStep.cs b/src/Umbraco.Core/DynamicRoot/QuerySteps/FarthestAncestorOrSelfDynamicRootQueryStep.cs new file mode 100644 index 0000000000..11303cf3d9 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/QuerySteps/FarthestAncestorOrSelfDynamicRootQueryStep.cs @@ -0,0 +1,37 @@ +using Umbraco.Cms.Core.Extensions; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.DynamicRoot.QuerySteps; + +public class FarthestAncestorOrSelfDynamicRootQueryStep : IDynamicRootQueryStep +{ + private readonly ICoreScopeProvider _scopeProvider; + private readonly IDynamicRootRepository _nodeFilterRepository; + + public FarthestAncestorOrSelfDynamicRootQueryStep(ICoreScopeProvider scopeProvider, IDynamicRootRepository nodeFilterRepository) + { + _scopeProvider = scopeProvider; + _nodeFilterRepository = nodeFilterRepository; + } + + protected virtual string SupportedDirectionAlias { get; set; } = "FarthestAncestorOrSelf"; + + public async Task>> ExecuteAsync(ICollection origins, DynamicRootQueryStep filter) + { + if (filter.Alias != SupportedDirectionAlias) + { + return Attempt>.Fail(); + } + + if (origins.Count < 1) + { + return Attempt>.Succeed(Array.Empty()); + } + + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + var result = (await _nodeFilterRepository.FarthestAncestorOrSelfAsync(origins, filter))?.ToSingleItemCollection() ?? Array.Empty(); + + return Attempt>.Succeed(result); + } +} diff --git a/src/Umbraco.Core/DynamicRoot/QuerySteps/FarthestDescendantOrSelfDynamicRootQueryStep.cs b/src/Umbraco.Core/DynamicRoot/QuerySteps/FarthestDescendantOrSelfDynamicRootQueryStep.cs new file mode 100644 index 0000000000..a67d1bdf73 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/QuerySteps/FarthestDescendantOrSelfDynamicRootQueryStep.cs @@ -0,0 +1,35 @@ +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.DynamicRoot.QuerySteps; + +public class FarthestDescendantOrSelfDynamicRootQueryStep : IDynamicRootQueryStep +{ + private readonly ICoreScopeProvider _scopeProvider; + private readonly IDynamicRootRepository _nodeFilterRepository; + + public FarthestDescendantOrSelfDynamicRootQueryStep(ICoreScopeProvider scopeProvider, IDynamicRootRepository nodeFilterRepository) + { + _scopeProvider = scopeProvider; + _nodeFilterRepository = nodeFilterRepository; + } + + protected virtual string SupportedDirectionAlias { get; set; } = "FarthestDescendantOrSelf"; + + public async Task>> ExecuteAsync(ICollection origins, DynamicRootQueryStep filter) + { + if (filter.Alias != SupportedDirectionAlias) + { + return Attempt>.Fail(); + } + + if (origins.Count < 1) + { + return Attempt>.Succeed(Array.Empty()); + } + + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + var result = await _nodeFilterRepository.FarthestDescendantOrSelfAsync(origins, filter); + + return Attempt>.Succeed(result); + } +} diff --git a/src/Umbraco.Core/DynamicRoot/QuerySteps/IDynamicRootQueryStep.cs b/src/Umbraco.Core/DynamicRoot/QuerySteps/IDynamicRootQueryStep.cs new file mode 100644 index 0000000000..a72b86474a --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/QuerySteps/IDynamicRootQueryStep.cs @@ -0,0 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Umbraco.Cms.Core.DynamicRoot.QuerySteps; + +public interface IDynamicRootQueryStep +{ + Task>> ExecuteAsync(ICollection origins, DynamicRootQueryStep filter); +} diff --git a/src/Umbraco.Core/DynamicRoot/QuerySteps/IDynamicRootRepository.cs b/src/Umbraco.Core/DynamicRoot/QuerySteps/IDynamicRootRepository.cs new file mode 100644 index 0000000000..10a35557a4 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/QuerySteps/IDynamicRootRepository.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Core.DynamicRoot.QuerySteps; + +public interface IDynamicRootRepository +{ + Task NearestAncestorOrSelfAsync(IEnumerable origins, DynamicRootQueryStep queryStep); + + Task FarthestAncestorOrSelfAsync(IEnumerable origins, DynamicRootQueryStep queryStep); + + Task> NearestDescendantOrSelfAsync(ICollection origins, DynamicRootQueryStep queryStep); + + Task> FarthestDescendantOrSelfAsync(ICollection origins, DynamicRootQueryStep queryStep); +} diff --git a/src/Umbraco.Core/DynamicRoot/QuerySteps/NearestAncestorOrSelfDynamicRootQueryStep.cs b/src/Umbraco.Core/DynamicRoot/QuerySteps/NearestAncestorOrSelfDynamicRootQueryStep.cs new file mode 100644 index 0000000000..0146283ef9 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/QuerySteps/NearestAncestorOrSelfDynamicRootQueryStep.cs @@ -0,0 +1,37 @@ +using Umbraco.Cms.Core.Extensions; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.DynamicRoot.QuerySteps; + +public class NearestAncestorOrSelfDynamicRootQueryStep : IDynamicRootQueryStep +{ + private readonly ICoreScopeProvider _scopeProvider; + private readonly IDynamicRootRepository _nodeFilterRepository; + + public NearestAncestorOrSelfDynamicRootQueryStep(ICoreScopeProvider scopeProvider, IDynamicRootRepository nodeFilterRepository) + { + _scopeProvider = scopeProvider; + _nodeFilterRepository = nodeFilterRepository; + } + + protected virtual string SupportedDirectionAlias { get; set; } = "NearestAncestorOrSelf"; + + public async Task>> ExecuteAsync(ICollection origins, DynamicRootQueryStep filter) + { + if (filter.Alias != SupportedDirectionAlias) + { + return Attempt>.Fail(); + } + + if (origins.Count < 1) + { + return Attempt>.Succeed(Array.Empty()); + } + + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + var result = (await _nodeFilterRepository.NearestAncestorOrSelfAsync(origins, filter))?.ToSingleItemCollection() ?? Array.Empty(); + + return Attempt>.Succeed(result); + } +} diff --git a/src/Umbraco.Core/DynamicRoot/QuerySteps/NearestDescendantOrSelfDynamicRootQueryStep.cs b/src/Umbraco.Core/DynamicRoot/QuerySteps/NearestDescendantOrSelfDynamicRootQueryStep.cs new file mode 100644 index 0000000000..1e36c79436 --- /dev/null +++ b/src/Umbraco.Core/DynamicRoot/QuerySteps/NearestDescendantOrSelfDynamicRootQueryStep.cs @@ -0,0 +1,35 @@ +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.DynamicRoot.QuerySteps; + +public class NearestDescendantOrSelfDynamicRootQueryStep : IDynamicRootQueryStep +{ + private readonly ICoreScopeProvider _scopeProvider; + private readonly IDynamicRootRepository _nodeFilterRepository; + + public NearestDescendantOrSelfDynamicRootQueryStep(ICoreScopeProvider scopeProvider, IDynamicRootRepository nodeFilterRepository) + { + _scopeProvider = scopeProvider; + _nodeFilterRepository = nodeFilterRepository; + } + + protected virtual string SupportedDirectionAlias { get; set; } = "NearestDescendantOrSelf"; + + public async Task>> ExecuteAsync(ICollection origins, DynamicRootQueryStep filter) + { + if (filter.Alias != SupportedDirectionAlias) + { + return Attempt>.Fail(); + } + + if (origins.Count < 1) + { + return Attempt>.Succeed(Array.Empty()); + } + + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + var result = await _nodeFilterRepository.NearestDescendantOrSelfAsync(origins, filter); + + return Attempt>.Succeed(result); + } +} diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index 73087fff30..a4217a3525 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -1221,6 +1221,41 @@ Mange hilsner fra Umbraco robotten Du kan kun vælge følgende type(r) dokumenter: %0% Du har valgt et dokument som er slettet eller lagt i papirkurven Du har valgt dokumenter som er slettede eller lagt i papirkurven + Afgræns udgangspunktet + Vælg udgangspunkt + Definer med XPath + Definer Dynamisk Udgangspunkt + + + Dynamisk udgangspunkts forespørgsel + Vælg begyndelsen + Beskriv begyndelsen for dynamisk udgangspunkts forespørgselen + Roden + Rod noden for denne kilde + Overliggende + Den overliggende node af kilden i denne redigerings session + Nuværende + Kilde noden for denne redigerings session + Siden + Nærmeste node med et domæne + Specifik Node + Vælg en specifik Node + Tilføj skridt til forespørgsel + Specificer næste skridt i din dynamisk udgangspunkts forespørgsel + Nærmeste forældre eller selv + Forespørg the nærmeste forældre eller selv der passer på en af de givne typer + Fjerneste forældre eller selv + Forespørg fjerneste forældre eller selv der passer på en af de givne typer + Nærmeste barn eller selv + Forespørg nærmeste barn eller selv der passer på en af de givne typer + Fjerneste barn eller selv + Forespørg fjerneste barn eller selv der passer på en af de givne typer + Brugerdefineret + Forespørg med et skræddersyet forespørgsels skridt + Tilføj skridt + der passer med typerne: + Intet passende indhold + Konfigurationen af dette felt passer ikke med noget indhold. Opret det manglende indhold eller kontakt din adminnistrator for at tilpasse Dynamisk Udgangspunkts Forespørgselen for dette felt. Slettet medie diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 8c35554043..6047de2b10 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -1431,6 +1431,41 @@ To manage your website, simply open the Umbraco backoffice and start adding cont You can only select items of type(s): %0% You have picked a content item currently deleted or in the recycle bin You have picked content items currently deleted or in the recycle bin + Specify root + Pick root node + Specify via XPath + Specify a Dynamic Root + + + Dynamic Root Query + Pick origin + Define the origin for your Dynamic Root Query + Root + Root node of this editing session + Parent + The parent node of the source in this editing session + Current + The content node that is source for this editing session + Site + Find nearest node with a hostname + Specific Node + Pick a specific Node as the origin for this query + Append step to query + Define the next step of your Dynamic Root Query + Nearest Ancestor Or Self + Query the nearest ancestor or self that fits with one of the configured types + Furthest Ancestor Or Self + Query the Furthest ancestor or self that fits with one of the configured types + Nearest Descendant Or Self + Query the nearest descendant or self that fits with one of the configured types + Furthest Descendant Or Self + Query the Furthest descendant or self that fits with one of the configured types + Custom + Query the using a custom Query Step + Add query step + That matches types: + No matching content + The configuration of this property does not match any content. Create the missing content or contact your adminnistrator to adjust the Dynamic Root settings for this property. Deleted item diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index 0cde55db08..c55b78a38b 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -1469,6 +1469,43 @@ To manage your website, simply open the Umbraco backoffice and start adding cont You can only select items of type(s): %0% You have picked a content item currently deleted or in the recycle bin You have picked content items currently deleted or in the recycle bin + Specify root + Pick root node + Specify root via XPath + Specify a Dynamic Root + Start node + XPath Query + + + Dynamic Root Query + Pick origin + Define the origin for your Dynamic Root Query + Root + Root node of this editing session + Parent + The parent node of the source in this editing session + Current + The content node that is source for this editing session + Site + Find nearest node with a hostname + Specific Node + Pick a specific Node as the origin for this query + Append step to query + Define the next step of your Dynamic Root Query + Nearest Ancestor Or Self + Query the nearest ancestor or self that fits with one of the configured types + Furthest Ancestor Or Self + Query the Furthest ancestor or self that fits with one of the configured types + Nearest Descendant Or Self + Query the nearest descendant or self that fits with one of the configured types + Furthest Descendant Or Self + Query the Furthest descendant or self that fits with one of the configured types + Custom + Query the using a custom Query Step + Add query step + That matches types: + No matching content + The configuration of this property does not match any content. Create the missing content or contact your adminnistrator to adjust the Dynamic Root settings for this property. Deleted item diff --git a/src/Umbraco.Core/Extensions/CollectionExtensions.cs b/src/Umbraco.Core/Extensions/CollectionExtensions.cs new file mode 100644 index 0000000000..fd2f976d50 --- /dev/null +++ b/src/Umbraco.Core/Extensions/CollectionExtensions.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Extensions; + +public static class CollectionExtensions +{ + // Easiest way to return a collection with 1 item, probably not the most performant + public static ICollection ToSingleItemCollection(this T item) => + new T[] { item }; +} diff --git a/src/Umbraco.Core/Extensions/EnumerableExtensions.cs b/src/Umbraco.Core/Extensions/EnumerableExtensions.cs index e2c0936fa4..7bbb010e2d 100644 --- a/src/Umbraco.Core/Extensions/EnumerableExtensions.cs +++ b/src/Umbraco.Core/Extensions/EnumerableExtensions.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Collections; namespace Umbraco.Extensions; diff --git a/src/Umbraco.Core/PropertyEditors/MultiNodePickerConfigurationTreeSource.cs b/src/Umbraco.Core/PropertyEditors/MultiNodePickerConfigurationTreeSource.cs index 2dcd0f6e93..ba6d605cca 100644 --- a/src/Umbraco.Core/PropertyEditors/MultiNodePickerConfigurationTreeSource.cs +++ b/src/Umbraco.Core/PropertyEditors/MultiNodePickerConfigurationTreeSource.cs @@ -14,6 +14,33 @@ public class MultiNodePickerConfigurationTreeSource [DataMember(Name = "query")] public string? StartNodeQuery { get; set; } + [DataMember(Name = "dynamicRoot")] + public DynamicRoot? DynamicRoot { get; set; } + [DataMember(Name = "id")] public Udi? StartNodeId { get; set; } } + +[DataContract] +public class DynamicRoot +{ + [DataMember(Name = "originAlias")] + public string OriginAlias { get; set; } = string.Empty; + + [DataMember(Name = "originKey")] + public Guid? OriginKey { get; set; } + + [DataMember(Name = "querySteps")] + public QueryStep[] QuerySteps { get; set; } = Array.Empty(); +} + +[DataContract] +public class QueryStep +{ + [DataMember(Name = "alias")] + public string Alias { get; set; } = string.Empty; + + [DataMember(Name = "anyOfDocTypeKeys")] + public IEnumerable AnyOfDocTypeKeys { get; set; } = Array.Empty(); +} + diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index df2ac91839..2e4832cff0 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.DynamicRoot; +using Umbraco.Cms.Core.DynamicRoot.QuerySteps; using Umbraco.Cms.Infrastructure.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; using Umbraco.Extensions; @@ -68,6 +70,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/Logging/Serilog/LoggerConfigExtensions.cs b/src/Umbraco.Infrastructure/Logging/Serilog/LoggerConfigExtensions.cs index 81afad16f8..b983f3e663 100644 --- a/src/Umbraco.Infrastructure/Logging/Serilog/LoggerConfigExtensions.cs +++ b/src/Umbraco.Infrastructure/Logging/Serilog/LoggerConfigExtensions.cs @@ -126,7 +126,7 @@ namespace Umbraco.Extensions /// A Serilog LoggerConfiguration /// /// The log level you wish the JSON file to collect - default is Verbose (highest) - /// + /// [Obsolete("Will be removed in Umbraco 13.")] public static LoggerConfiguration OutputDefaultTextFile( this LoggerConfiguration logConfig, diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DynamicRootRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DynamicRootRepository.cs new file mode 100644 index 0000000000..b634f45125 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DynamicRootRepository.cs @@ -0,0 +1,125 @@ +using NPoco; +using Umbraco.Cms.Core.DynamicRoot.QuerySteps; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +public class DynamicRootRepository: IDynamicRootRepository +{ + private readonly IScopeAccessor _scopeAccessor; + + public DynamicRootRepository(IScopeAccessor scopeAccessor) => _scopeAccessor = scopeAccessor; + + private IUmbracoDatabase Database + { + get + { + if (_scopeAccessor.AmbientScope is null) + { + throw new NotSupportedException("Need to be executed in a scope"); + } + + return _scopeAccessor.AmbientScope.Database; + } + } + + public async Task NearestAncestorOrSelfAsync(IEnumerable origins, DynamicRootQueryStep filter) + { + Sql query = Database.SqlContext.SqlSyntax.SelectTop( + GetAncestorOrSelfBaseQuery(origins, filter) + .Append($"ORDER BY n.level DESC"), + 1); + + return await Database.SingleOrDefaultAsync(query); + } + + public async Task FarthestAncestorOrSelfAsync(IEnumerable origins, DynamicRootQueryStep filter) { + Sql query = Database.SqlContext.SqlSyntax.SelectTop( + GetAncestorOrSelfBaseQuery(origins, filter) + .Append($"ORDER BY n.level ASC"), + 1); + + return await Database.SingleOrDefaultAsync(query); + } + + private Sql GetAncestorOrSelfBaseQuery(IEnumerable origins, DynamicRootQueryStep filter) + { + var query = Database.SqlContext.Sql() + .Select("n", n => n.UniqueId) + .From("norigin") + .Append( // hack because npoco do not support this + $"INNER JOIN {Database.SqlContext.SqlSyntax.GetQuotedTableName(NodeDto.TableName)} n ON {Database.SqlContext.SqlSyntax.Substring}(norigin.path, 1, {Database.SqlContext.SqlSyntax.Length}(n.path)) = n.path") + .InnerJoin("c") + .On((c, n) => c.NodeId == n.NodeId, "c", "n") + .InnerJoin("ct") + .On((c, ct) => c.ContentTypeId == ct.NodeId, "c", "ct") + .InnerJoin("ctn") + .On((ct, ctn) => ct.NodeId == ctn.NodeId, "ct", "ctn") + .Where(norigin => origins.Contains(norigin.UniqueId), "norigin"); + + if (filter.AnyOfDocTypeKeys.Any()) + { + query = query.Where(ctn => filter.AnyOfDocTypeKeys.Contains(ctn.UniqueId), "ctn"); + } + + return query; + } + + + public async Task> NearestDescendantOrSelfAsync(ICollection origins, DynamicRootQueryStep filter) + { + var level = Database.Single(Database.SqlContext.Sql() + .Select("COALESCE(MIN(n.level), 0)") + .DescendantOrSelfBaseQuery(origins, filter)); + + Sql query = + Database.SqlContext.Sql() + .Select("n", n => n.UniqueId) + .DescendantOrSelfBaseQuery(origins, filter) + .Where(n => n.Level == level, "n"); + + return await Database.FetchAsync(query); + } + + public async Task> FarthestDescendantOrSelfAsync(ICollection origins, DynamicRootQueryStep filter) + { + var level = Database.Single(Database.SqlContext.Sql() + .Select("COALESCE(MAX(n.level), 0)") + .DescendantOrSelfBaseQuery(origins, filter)); + + Sql query = + Database.SqlContext.Sql() + .Select("n", n => n.UniqueId) + .DescendantOrSelfBaseQuery(origins, filter) + .Where(n => n.Level == level, "n"); + + return await Database.FetchAsync(query); + } +} + +internal static class HelperExtensions +{ + internal static Sql DescendantOrSelfBaseQuery(this Sql sql, IEnumerable origins, DynamicRootQueryStep filter) + { + var query = sql + .From("norigin") + .Append(// hack because npoco do not support this + $"INNER JOIN {sql.SqlContext.SqlSyntax.GetQuotedTableName(NodeDto.TableName)} n ON {sql.SqlContext.SqlSyntax.Substring}(N.path, 1, {sql.SqlContext.SqlSyntax.Length}(norigin.path)) = norigin.path") + .InnerJoin("c") + .On((c, n) => c.NodeId == n.NodeId, "c", "n") + .InnerJoin("ct") + .On((c, ct) => c.ContentTypeId == ct.NodeId, "c", "ct") + .InnerJoin("ctn") + .On((ct, ctn) => ct.NodeId == ctn.NodeId, "ct", "ctn") + .Where(norigin => origins.Contains(norigin.UniqueId), "norigin"); + + if (filter.AnyOfDocTypeKeys.Any()) + { + query = query.Where(ctn => filter.AnyOfDocTypeKeys.Contains(ctn.UniqueId), "ctn"); + } + + return query; + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs index a71ccf5bed..d1a2c1b0d2 100644 --- a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs +++ b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs @@ -14,6 +14,10 @@ namespace Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; /// public interface ISqlSyntaxProvider { + string Length { get; } + + string Substring { get; } + string ProviderName { get; } string CreateTable { get; } diff --git a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs index 15cb68bfc5..992896901e 100644 --- a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs @@ -421,6 +421,9 @@ public abstract class SqlSyntaxProviderBase : ISqlSyntaxProvider public virtual string DeleteDefaultConstraint => throw new NotSupportedException("Default constraints are not supported"); + public virtual string Length => "LEN"; + public virtual string Substring => "SUBSTRING"; + public virtual string CreateTable => "CREATE TABLE {0} ({1})"; public virtual string DropTable => "DROP TABLE {0}"; diff --git a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs index fedf8ceba4..38231740ba 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs @@ -9,8 +9,11 @@ using Examine.Search; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; @@ -19,9 +22,12 @@ using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Models.TemplateQuery; using Umbraco.Cms.Core.Persistence; +using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.DynamicRoot; +using Umbraco.Cms.Core.DynamicRoot.QuerySteps; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Core.Xml; @@ -62,6 +68,7 @@ public class EntityController : UmbracoAuthorizedJsonController private static readonly string[] _postFilterSplitStrings = { "=", "==", "!=", "<>", ">", "<", ">=", "<=" }; private readonly AppCaches _appCaches; + private readonly IDynamicRootService _dynamicRootService; private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; private readonly IContentService _contentService; private readonly IContentTypeService _contentTypeService; @@ -82,6 +89,7 @@ public class EntityController : UmbracoAuthorizedJsonController private readonly IUmbracoMapper _umbracoMapper; private readonly IUserService _userService; + [ActivatorUtilitiesConstructor] public EntityController( ITreeService treeService, UmbracoTreeSearcher treeSearcher, @@ -102,7 +110,8 @@ public class EntityController : UmbracoAuthorizedJsonController IMacroService macroService, IUserService userService, ILocalizationService localizationService, - AppCaches appCaches) + AppCaches appCaches, + IDynamicRootService dynamicRootService) { _treeService = treeService ?? throw new ArgumentNullException(nameof(treeService)); _treeSearcher = treeSearcher ?? throw new ArgumentNullException(nameof(treeSearcher)); @@ -129,6 +138,54 @@ public class EntityController : UmbracoAuthorizedJsonController _userService = userService ?? throw new ArgumentNullException(nameof(userService)); _localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); _appCaches = appCaches ?? throw new ArgumentNullException(nameof(appCaches)); + _dynamicRootService = dynamicRootService; + } + + [Obsolete("Use non-obsolete ctor. This will be removed in Umbraco 14.")] + public EntityController( + ITreeService treeService, + UmbracoTreeSearcher treeSearcher, + SearchableTreeCollection searchableTreeCollection, + IPublishedContentQuery publishedContentQuery, + IShortStringHelper shortStringHelper, + IEntityService entityService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IPublishedUrlProvider publishedUrlProvider, + IContentService contentService, + IUmbracoMapper umbracoMapper, + IDataTypeService dataTypeService, + ISqlContext sqlContext, + ILocalizedTextService localizedTextService, + IFileService fileService, + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IMacroService macroService, + IUserService userService, + ILocalizationService localizationService, + AppCaches appCaches): this( + treeService, + treeSearcher, + searchableTreeCollection, + publishedContentQuery, + shortStringHelper, + entityService, + backofficeSecurityAccessor, + publishedUrlProvider, + contentService, + umbracoMapper, + dataTypeService, + sqlContext, + localizedTextService, + fileService, + contentTypeService, + mediaTypeService, + macroService, + userService, + localizationService, + appCaches, + StaticServiceProvider.Instance.GetRequiredService()) + { + } @@ -525,6 +582,52 @@ public class EntityController : UmbracoAuthorizedJsonController [Obsolete("This will be removed in Umbraco 13. Use GetByXPath instead")] public ActionResult? GetByQuery(string query, int nodeContextId, UmbracoEntityTypes type) => GetByXPath(query, nodeContextId, null, type); + public class DynamicRootViewModel + { + public DynamicRoot Query { get; set; } = null!; + + public int CurrentId { get; set; } + + public int ParentId { get; set; } + } + + [HttpPost] + public async Task> GetDynamicRootAsync([FromBody]DynamicRootViewModel model) + { + var currentKey = model.CurrentId == 0 ? null : _entityService.Get(model.CurrentId)?.Key; + var parentKey = model.ParentId == 0 ? null : _entityService.Get(model.ParentId)?.Key; + + if (parentKey is null) + { + throw new ArgumentException("Invalid parentId", nameof(model.ParentId)); + } + + var startNodeSelector = new DynamicRootNodeQuery() + { + Context = new DynamicRootContext() + { + CurrentKey = currentKey, + ParentKey = parentKey.Value + }, + OriginKey = model.Query.OriginKey, + OriginAlias = model.Query.OriginAlias, + QuerySteps = model.Query.QuerySteps.Select(x=>new DynamicRootQueryStep() + { + Alias = x.Alias, + AnyOfDocTypeKeys = x.AnyOfDocTypeKeys + }) + }; + var startNodes = (await _dynamicRootService.GetDynamicRootsAsync(startNodeSelector)).ToArray(); + + Guid? first = startNodes.Any() ? startNodes.First() : null; + if (first.HasValue) + { + return GetById(first.Value, UmbracoEntityTypes.Document); + } + + return Ok(); + } + /// /// Gets an entity by a xpath query /// @@ -1630,3 +1733,5 @@ public class EntityController : UmbracoAuthorizedJsonController #endregion } + + diff --git a/src/Umbraco.Web.UI.Client/src/common/filters/nestedcontent.filter.js b/src/Umbraco.Web.UI.Client/src/common/filters/nestedcontent.filter.js index b0ea8be9a3..2d09b521e3 100644 --- a/src/Umbraco.Web.UI.Client/src/common/filters/nestedcontent.filter.js +++ b/src/Umbraco.Web.UI.Client/src/common/filters/nestedcontent.filter.js @@ -26,6 +26,9 @@ angular.module("umbraco.filters").filter("ncNodeName", function (editorState, en var currentNode = editorState.getCurrent(); + // Enable using keys with dashes: + input = input.split('-').join(''); + // Ensure a unique cache per editor instance var key = "ncNodeName_" + currentNode.key; if (ncNodeNameCache.id !== key) { diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js index 6f46268b5b..6e70f1dbb8 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js @@ -366,6 +366,20 @@ function entityResource($q, $http, umbRequestHelper) { 'Failed to retrieve entity data for query ' + query); }, + getDynamicRoot: function (query, currentId, parentId) { + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "entityApiBaseUrl", + "getDynamicRoot"), + { + query: JSON.parse(query), + parentId: parentId, + currentId: currentId + }), + 'Failed to retrieve entity data for query ' + query); + }, + /** * @ngdoc method * @name umbraco.resources.entityResource#getAll diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less index 7e42d0e46e..0173592de6 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less @@ -102,10 +102,15 @@ width: 100%; } +.umb-node-preview-add + .umb-node-preview-add { + margin-left: -1px; +} + .umb-node-preview-add:hover { color: @ui-action-discreet-type-hover; border-color: @ui-action-discreet-border-hover; text-decoration: none; + z-index:1; } .umb-node-preview-add:disabled { @@ -136,4 +141,4 @@ border: 1px solid @gray-9; padding: 12px 15px; border-radius: @baseBorderRadius; -} \ No newline at end of file +} diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootcustomstep/pickdynamicrootcustomstep.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootcustomstep/pickdynamicrootcustomstep.controller.js new file mode 100644 index 0000000000..1af37043f1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootcustomstep/pickdynamicrootcustomstep.controller.js @@ -0,0 +1,40 @@ +(function () { + "use strict"; + + function PickDynamicRootCustomStepController($scope, localizationService) { + + var vm = this; + + function onInit() { + if(!$scope.model.title) { + localizationService.localize("dynamicRoot_pickDynamicRootQueryStepTitle").then(function(value){ + $scope.model.title = value; + }); + } + if(!$scope.model.subtitle) { + localizationService.localize("dynamicRoot_pickDynamicRootQueryStepDesc").then(function(value){ + $scope.model.subtitle = value; + }); + } + } + + vm.submit = submit; + function submit() { + if ($scope.model.submit) { + $scope.model.submit($scope.model); + } + } + + vm.close = close; + function close() { + if($scope.model.close) { + $scope.model.close(); + } + } + + onInit(); + + } + + angular.module("umbraco").controller("Umbraco.Editors.PickDynamicRootCustomStep", PickDynamicRootCustomStepController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootcustomstep/pickdynamicrootcustomstep.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootcustomstep/pickdynamicrootcustomstep.html new file mode 100644 index 0000000000..3c1a590300 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootcustomstep/pickdynamicrootcustomstep.html @@ -0,0 +1,55 @@ +
+ + + + + + + + + + +
+ +
+ +
+
+
+
+ +
+ + + + + + + + + + +
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootorigin/pickdynamicrootorigin.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootorigin/pickdynamicrootorigin.controller.js new file mode 100644 index 0000000000..9851c5f710 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootorigin/pickdynamicrootorigin.controller.js @@ -0,0 +1,82 @@ +(function () { + "use strict"; + + function PickDynamicRootOriginController($scope, localizationService, editorService, udiParser) { + + var vm = this; + + function onInit() { + + if(!$scope.model.title) { + localizationService.localize("dynamicRoot_pickDynamicRootOriginTitle").then(function(value){ + $scope.model.title = value; + }); + } + if(!$scope.model.subtitle) { + localizationService.localize("dynamicRoot_pickDynamicRootOriginDesc").then(function(value){ + $scope.model.subtitle = value; + }); + } + + } + + vm.chooseRoot = function() { + $scope.model.value.originAlias = "Root"; + $scope.model.value.originKey = null; + vm.submit($scope.model); + } + vm.chooseParent = function() { + $scope.model.value.originAlias = "Parent"; + $scope.model.value.originKey = null; + vm.submit($scope.model); + } + vm.chooseCurrent = function() { + $scope.model.value.originAlias = "Current"; + $scope.model.value.originKey = null; + vm.submit($scope.model); + } + vm.chooseSite = function() { + $scope.model.value.originAlias = "Site"; + $scope.model.value.originKey = null; + vm.submit($scope.model); + } + vm.chooseByKey = function() { + var treePicker = { + idType: "udi", + section: $scope.model.contentType, + treeAlias: $scope.model.contentType, + multiPicker: false, + submit: function(model) { + var item = model.selection[0]; + $scope.model.value.originAlias = "ByKey"; + $scope.model.value.originKey = udiParser.parse(item.udi).value; + editorService.close(); + vm.submit($scope.model); + }, + close: function() { + editorService.close(); + } + }; + editorService.treePicker(treePicker); + } + + vm.submit = submit; + function submit(model) { + if ($scope.model.submit) { + $scope.model.submit(model); + } + } + + vm.close = close; + function close() { + if($scope.model.close) { + $scope.model.close(); + } + } + + onInit(); + + } + + angular.module("umbraco").controller("Umbraco.Editors.PickDynamicRootOrigin", PickDynamicRootOriginController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootorigin/pickdynamicrootorigin.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootorigin/pickdynamicrootorigin.html new file mode 100644 index 0000000000..2b5933f6cb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootorigin/pickdynamicrootorigin.html @@ -0,0 +1,58 @@ +
+ + + + + + + + + + +
+ + + + + +
+
+
+ +
+ + + + + + + + +
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootquerystep/pickdynamicrootquerystep.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootquerystep/pickdynamicrootquerystep.controller.js new file mode 100644 index 0000000000..00005aa019 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootquerystep/pickdynamicrootquerystep.controller.js @@ -0,0 +1,91 @@ +(function () { + "use strict"; + + function PickDynamicRootQueryStepController($scope, localizationService, editorService, udiParser) { + + var vm = this; + + function onInit() { + if(!$scope.model.title) { + localizationService.localize("dynamicRoot_pickDynamicRootQueryStepTitle").then(function(value){ + $scope.model.title = value; + }); + } + if(!$scope.model.subtitle) { + localizationService.localize("dynamicRoot_pickDynamicRootQueryStepDesc").then(function(value){ + $scope.model.subtitle = value; + }); + } + } + + vm.choose = function(queryStepAlias) { + var editor = { + multiPicker: true, + filterCssClass: "not-allowed not-published", + filter: function (item) { + // filter out folders (containers), element types (for content) + return item.nodeType === "container" || item.metaData.isElement; + }, + submit: function (model) { + var typeKeys = _.map(model.selection, function(selected) { return udiParser.parse(selected.udi).value; }); + $scope.model.value = { + alias: queryStepAlias, + anyOfDocTypeKeys: typeKeys + } + editorService.close(); + vm.submit($scope.model); + }, + close: function() { + editorService.close(); + } + }; + + switch ($scope.model.contentType) { + case "content": + editorService.contentTypePicker(editor); + break; + case "media": + editorService.mediaTypePicker(editor); + break; + } + } + + vm.chooseCustom = function() { + var customStepPicker = { + view: "views/common/infiniteeditors/pickdynamicrootcustomstep/pickdynamicrootcustomstep.html", + size: "small", + value: "", + submit: function(model) { + $scope.model.value = { + alias: model.value + } + editorService.close(); + vm.submit($scope.model); + }, + close: function() { + editorService.close(); + } + }; + editorService.open(customStepPicker); + } + + vm.submit = submit; + function submit(model) { + if ($scope.model.submit) { + $scope.model.submit(model); + } + } + + vm.close = close; + function close() { + if($scope.model.close) { + $scope.model.close(); + } + } + + onInit(); + + } + + angular.module("umbraco").controller("Umbraco.Editors.PickDynamicRootQueryStep", PickDynamicRootQueryStepController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootquerystep/pickdynamicrootquerystep.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootquerystep/pickdynamicrootquerystep.html new file mode 100644 index 0000000000..a887cdd425 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/pickdynamicrootquerystep/pickdynamicrootquerystep.html @@ -0,0 +1,58 @@ +
+ + + + + + + + + + +
+ + + + + +
+
+
+ +
+ + + + + + + + +
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/prevalueeditors/treesource.controller.js b/src/Umbraco.Web.UI.Client/src/views/prevalueeditors/treesource.controller.js index f9c8ae8b0e..7821ccb396 100644 --- a/src/Umbraco.Web.UI.Client/src/views/prevalueeditors/treesource.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/prevalueeditors/treesource.controller.js @@ -2,48 +2,49 @@ //with a specified callback, this callback will receive an object with a selection on it angular.module('umbraco') .controller("Umbraco.PrevalueEditors.TreeSourceController", - + function($scope, $timeout, entityResource, iconHelper, editorService, eventsService){ - if (!$scope.model) { - $scope.model = {}; - } - if (!$scope.model.value) { - $scope.model.value = { - type: "content" - }; - } - if (!$scope.model.config) { - $scope.model.config = { - idType: "udi" - }; - } + $scope.showXPath = false; - if($scope.model.value.id && $scope.model.value.type !== "member"){ - entityResource.getById($scope.model.value.id, entityType()).then(function(item){ - populate(item); - }); - } - else { - $timeout(function () { - treeSourceChanged(); - }, 100); - } + if (!$scope.model) { + $scope.model = {}; + } + if (!$scope.model.value) { + $scope.model.value = { + type: "content" + }; + } + if (!$scope.model.config) { + $scope.model.config = { + idType: "udi" + }; + } - function entityType() { - var ent = "Document"; - if($scope.model.value.type === "media"){ - ent = "Media"; - } - else if ($scope.model.value.type === "member") { - ent = "Member"; - } - return ent; - } + if($scope.model.value.id && $scope.model.value.type !== "member"){ + entityResource.getById($scope.model.value.id, entityType()).then(function(item){ + populate(item); + }); + } else { + $timeout(function () { + treeSourceChanged(); + }, 100); + } - $scope.openContentPicker =function(){ + function entityType() { + var ent = "Document"; + if($scope.model.value.type === "media"){ + ent = "Media"; + } + else if ($scope.model.value.type === "member") { + ent = "Member"; + } + return ent; + } + + $scope.openContentPicker = function() { var treePicker = { - idType: $scope.model.config.idType, + idType: $scope.model.config.idType, section: $scope.model.value.type, treeAlias: $scope.model.value.type, multiPicker: false, @@ -59,39 +60,152 @@ angular.module('umbraco') editorService.treePicker(treePicker); }; - $scope.clear = function() { - $scope.model.value.id = null; - $scope.node = null; - $scope.model.value.query = null; - - treeSourceChanged(); + $scope.chooseXPath = function() { + $scope.showXPath = true; + $scope.model.value.dynamicRoot = null; + }; + $scope.chooseDynamicStartNode = function() { + $scope.showXPath = false; + $scope.model.value.dynamicRoot = { + originAlias: "Parent", + querySteps: [] + }; }; - function treeSourceChanged() { - eventsService.emit("treeSourceChanged", { value: $scope.model.value.type }); - } + $scope.clearXPath = function() { + $scope.model.value.query = null; + $scope.showXPath = false; + }; + $scope.clearDynamicStartNode = function() { + $scope.model.value.dynamicRoot = null; + $scope.showDynamicStartNode = false; + }; + + $scope.clear = function() { + $scope.model.value.id = null; + $scope.node = null; + $scope.model.value.query = null; + $scope.model.value.dynamicRoot = null; + treeSourceChanged(); + }; + + function treeSourceChanged() { + eventsService.emit("treeSourceChanged", { value: $scope.model.value.type }); + } //we always need to ensure we dont submit anything broken - var unsubscribe = $scope.$on("formSubmitting", function (ev, args) { - if($scope.model.value.type === "member"){ - $scope.model.value.id = null; - $scope.model.value.query = ""; - } - }); + var unsubscribe = $scope.$on("formSubmitting", function (ev, args) { + if($scope.model.value.type === "member") { + $scope.model.value.id = null; + $scope.model.value.query = ""; + $scope.model.value.dynamicRoot = null; + } + }); - //when the scope is destroyed we need to unsubscribe - $scope.$on('$destroy', function () { - unsubscribe(); - }); + //when the scope is destroyed we need to unsubscribe + $scope.$on('$destroy', function () { + unsubscribe(); + }); - function populate(item){ + function populate(item) { $scope.clear(); item.icon = iconHelper.convertFromLegacyIcon(item.icon); $scope.node = item; - $scope.node.path = ""; - $scope.model.value.id = $scope.model.config.idType === "udi" ? item.udi : item.id; - entityResource.getUrl(item.id, entityType()).then(function (data) { - $scope.node.path = data; - }); + $scope.node.path = ""; + $scope.model.value.id = $scope.model.config.idType === "udi" ? item.udi : item.id; + entityResource.getUrl(item.id, entityType()).then(function (data) { + $scope.node.path = data; + }); } + + + // Dynamic Root specific: + + $scope.dynamicRootOriginIcon = null; + $scope.$watch("model.value.dynamicRoot.originAlias", function (newVal, oldVal) { + $scope.dynamicRootOriginIcon = getIconForOriginAlias(newVal); + }) + function getIconForOriginAlias(originAlias) { + switch (originAlias) { + case "Root": + return "icon-home"; + case "Parent": + return "icon-page-up"; + case "Current": + return "icon-document"; + case "Site": + return "icon-home"; + case "ByKey": + return "icon-wand"; + } + } + $scope.getIconForQueryStepAlias = getIconForQueryStepAlias; + function getIconForQueryStepAlias(originAlias) { + switch (originAlias) { + case "NearestAncestorOrSelf": + return "icon-chevron-up"; + case "FurthestAncestorOrSelf": + return "icon-chevron-up"; + case "NearestDescendantOrSelf": + return "icon-chevron-down"; + case "FurthestDescendantOrSelf": + return "icon-chevron-down"; + } + return "icon-lab"; + } + + $scope.sortableOptionsForQuerySteps = { + axis: "y", + containment: "parent", + distance: 10, + opacity: 0.7, + tolerance: "pointer", + scroll: true, + zIndex: 6000 + }; + + $scope.removeQueryStep = function (queryStep) { + var index = $scope.model.value.dynamicRoot.querySteps.indexOf(queryStep); + if(index !== -1) { + $scope.model.value.dynamicRoot.querySteps.splice(index, 1); + } + } + + $scope.openDynamicRootOriginPicker = function() { + var originPicker = { + view: "views/common/infiniteeditors/pickdynamicrootorigin/pickdynamicrootorigin.html", + contentType: $scope.model.value.type, + size: "small", + value: {...$scope.model.value.dynamicRoot}, + multiPicker: false, + submit: function(model) { + $scope.model.value.dynamicRoot = model.value; + editorService.close(); + }, + close: function() { + editorService.close(); + } + }; + editorService.open(originPicker); + }; + + $scope.appendDynamicQueryStep = function() { + var queryStepPicker = { + view: "views/common/infiniteeditors/pickdynamicrootquerystep/pickdynamicrootquerystep.html", + contentType: $scope.model.value.type, + size: "small", + multiPicker: false, + submit: function(model) { + if(!$scope.model.value.dynamicRoot.querySteps) { + $scope.model.value.dynamicRoot.querySteps = []; + } + $scope.model.value.dynamicRoot.querySteps.push(model.value); + editorService.close(); + }, + close: function() { + editorService.close(); + } + }; + editorService.open(queryStepPicker); + }; }); diff --git a/src/Umbraco.Web.UI.Client/src/views/prevalueeditors/treesource.html b/src/Umbraco.Web.UI.Client/src/views/prevalueeditors/treesource.html index 0ab66c964e..32bccca77a 100644 --- a/src/Umbraco.Web.UI.Client/src/views/prevalueeditors/treesource.html +++ b/src/Umbraco.Web.UI.Client/src/views/prevalueeditors/treesource.html @@ -6,9 +6,10 @@ - Root node + -
-
- -
- - -
+
+
+ + +
-
+
+ +
XPath Query
A placeholder finds the nearest published ID and runs its query from there, so for instance:

- +
$parent/newsArticle
- +

Will try to get the parent if available, but will then fall back to the nearest ancestor and query for all news article children there.

@@ -72,10 +85,80 @@
  • - +
  • +
    + +
    Dynamic Root Query
    + + +
    +
    + +
    +
    + + +
    +
    + {{ ("umb://" + (model.value.type === 'content' ? 'document' : model.value.type) + "/" + model.value.dynamicRoot.originKey | ncNodeName)}} +
    +
    +
    +
    + +
    +
    + + +
    +
    +
    + +
    +
    + + {{queryStep.alias}} +
    +
    + + of type: + + + {{ key | umbCmsJoinArray:', '}} + +
    +
    +
    +
    + +
    +
    +
    + + + + +
      +
    • + + +
    • +
    +
    diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js index 4deb10d0de..7e4ccb71e8 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js @@ -98,6 +98,7 @@ function contentPickerController($scope, $q, $routeParams, $location, entityReso minNumber: 0, startNode: { query: "", + dynamicRoot: null, type: "content", id: $scope.model.config.startNodeId ? $scope.model.config.startNodeId : -1 // get start node for simple Content Picker } @@ -125,7 +126,7 @@ function contentPickerController($scope, $q, $routeParams, $location, entityReso if ($scope.model.validation && $scope.model.validation.mandatory && !$scope.model.config.minNumber) { $scope.model.config.minNumber = 1; } - + if ($scope.model.config.multiPicker === true && $scope.umbProperty) { var propertyActions = [ removeAllEntriesAction @@ -165,7 +166,7 @@ function contentPickerController($scope, $q, $routeParams, $location, entityReso : $scope.model.config.startNode.type === "media" ? "Media" : "Document"; - + $scope.allowOpenButton = false; $scope.allowEditButton = entityType === "Document" && !$scope.readonly; $scope.allowRemove = !$scope.readonly; @@ -255,12 +256,47 @@ function contentPickerController($scope, $q, $routeParams, $location, entityReso dialogOptions.startNodeId = ($scope.model.config.idType === "udi" ? ent.udi : ent.id).toString(); }); } + else if ($scope.model.config.startNode.dynamicRoot) { + entityResource.getDynamicRoot( + JSON.stringify($scope.model.config.startNode.dynamicRoot), + editorState.current.id, + editorState.current.parentId, + "Document" + ).then(function (ent) { + if(ent) { + dialogOptions.startNodeId = ($scope.model.config.idType === "udi" ? ent.udi : ent.id).toString(); + } else { + console.error("The Dynamic Root query did not find any valid results"); + $scope.invalidStartNode = true; + } + }); + } + else { dialogOptions.startNodeId = $scope.model.config.startNode.id; } //dialog $scope.openCurrentPicker = function () { + if($scope.invalidStartNode) { + + localizationService.localizeMany(["dynamicRoot_noValidStartNodeTitle", "dynamicRoot_noValidStartNodeDesc"]).then(function (data) { + overlayService.open({ + title: data[0], + content: data[1], + hideSubmitButton: true, + close: () => { + overlayService.close(); + }, + submit: () => { + // close the confirmation + overlayService.close(); + } + }); + }); + return; + } + $scope.currentPicker = dialogOptions; $scope.currentPicker.submit = function (model) { @@ -351,7 +387,7 @@ function contentPickerController($scope, $q, $routeParams, $location, entityReso var node = entityType === "Member" ? model.memberNode : entityType === "Media" ? model.mediaNode : model.contentNode; - + // update the node item.name = node.name; @@ -556,7 +592,7 @@ function contentPickerController($scope, $q, $routeParams, $location, entityReso } function init() { - + userService.getCurrentUser().then(function (user) { switch (entityType) { case "Document": diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DynamicRootServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DynamicRootServiceTests.cs new file mode 100644 index 0000000000..e9d277b325 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DynamicRootServiceTests.cs @@ -0,0 +1,719 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Diagnostics.CodeAnalysis; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.DynamicRoot; +using Umbraco.Cms.Core.DynamicRoot.QuerySteps; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +/// +/// Tests covering the DynamicRootService +/// +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +[SuppressMessage("ReSharper", "NotNullOrRequiredMemberIsNotInitialized")] +public class DynamicRootServiceTests : UmbracoIntegrationTest +{ + public enum DynamicRootOrigin + { + Root, + Parent, + Current, + Site, + ByKey + } + + public enum DynamicRootStepAlias + { + NearestAncestorOrSelf, + NearestDescendantOrSelf, + FarthestDescendantOrSelf, + } + + protected IContentTypeService ContentTypeService => GetRequiredService(); + + protected IFileService FileService => GetRequiredService(); + + protected ContentService ContentService => (ContentService)GetRequiredService(); + + private DynamicRootService DynamicRootService => (GetRequiredService() as DynamicRootService)!; + + private IDomainService DomainService => GetRequiredService(); + + private ContentType ContentTypeYears { get; set; } + + private ContentType ContentTypeYear { get; set; } + + private ContentType ContentTypeAct { get; set; } + + private ContentType ContentTypeActs { get; set; } + + private ContentType ContentTypeStages { get; set; } + + private ContentType ContentTypeStage { get; set; } + + private Content ContentYears { get; set; } + + private Content ContentYear2022 { get; set; } + + private Content ContentActs2022 { get; set; } + + private Content ContentAct2022RanD { get; set; } + + private Content ContentStages2022 { get; set; } + + private Content ContentStage2022Red { get; set; } + + private Content ContentStage2022Blue { get; set; } + + private Content ContentYear2023 { get; set; } + + private Content ContentYear2024 { get; set; } + + private Content Trashed { get; set; } + + + [SetUp] + public new void Setup() + { + // Root + // - Years (years) + // - 2022 (year) + // - Acts + // - Ran-D (Act) + // - Stages (stages) + // - Red (Stage) + // - Blue (Stage) + // - 2023 + // - Acts + // - Stages + // - 2024 + // - Acts + // - Stages + + // NOTE Maybe not the best way to create/save test data as we are using the services, which are being tested. + var template = TemplateBuilder.CreateTextPageTemplate(); + FileService.SaveTemplate(template); + + // DocTypes + ContentTypeAct = ContentTypeBuilder.CreateSimpleContentType("act", "Act", defaultTemplateId: template.Id); + ContentTypeAct.Key = new Guid("B3A50C84-5F6E-473A-A0B5-D41CBEC4EB36"); + ContentTypeService.Save(ContentTypeAct); + + ContentTypeStage = ContentTypeBuilder.CreateSimpleContentType("stage", "Stage", defaultTemplateId: template.Id); + ContentTypeStage.Key = new Guid("C6DCDB3C-9D4B-4F91-9D1C-8C3B74AECA45"); + ContentTypeService.Save(ContentTypeStage); + + ContentTypeStages = + ContentTypeBuilder.CreateSimpleContentType("stages", "Stages", defaultTemplateId: template.Id); + ContentTypeStages.Key = new Guid("BFC4C6C1-51D0-4538-B818-042BEEA0461E"); + ContentTypeStages.AllowedContentTypes = new[] { new ContentTypeSort(ContentTypeStage.Id, 0) }; + ContentTypeService.Save(ContentTypeStages); + + ContentTypeActs = ContentTypeBuilder.CreateSimpleContentType("acts", "Acts", defaultTemplateId: template.Id); + ContentTypeActs.Key = new Guid("110B6BC7-59E0-427D-B350-E488786788E7"); + ContentTypeActs.AllowedContentTypes = new[] { new ContentTypeSort(ContentTypeAct.Id, 0) }; + ContentTypeService.Save(ContentTypeActs); + + ContentTypeYear = ContentTypeBuilder.CreateSimpleContentType("year", "Year", defaultTemplateId: template.Id); + ContentTypeYear.Key = new Guid("001E9029-6BF9-4A68-B11E-7730109E4E28"); + ContentTypeYear.AllowedContentTypes = new[] + { + new ContentTypeSort(ContentTypeStages.Id, 0), new ContentTypeSort(ContentTypeActs.Id, 1), + }; + ContentTypeService.Save(ContentTypeYear); + + ContentTypeYears = ContentTypeBuilder.CreateSimpleContentType("years", "Years", defaultTemplateId: template.Id); + ContentTypeYears.Key = new Guid("1D3A8E6E-2EA9-4CC1-B229-1AEE19821522"); + ContentTypeActs.AllowedContentTypes = new[] { new ContentTypeSort(ContentTypeYear.Id, 0) }; + ContentTypeService.Save(ContentTypeYears); + + ContentYears = ContentBuilder.CreateSimpleContent(ContentTypeYears, "Years"); + ContentYears.Key = new Guid("CD3BBE28-D03F-422B-9DC6-A0E591543A8E"); + ContentService.Save(ContentYears, -1); + + ContentYear2022 = ContentBuilder.CreateSimpleContent(ContentTypeYear, "2022", ContentYears.Id); + ContentYear2022.Key = new Guid("9B3066E3-3CE9-4DF6-82C7-444236FF4DAC"); + ContentService.Save(ContentYear2022, -1); + + ContentActs2022 = ContentBuilder.CreateSimpleContent(ContentTypeActs, "Acts", ContentYear2022.Id); + ContentActs2022.Key = new Guid("6FD7F030-269D-45BE-BEB4-030FF8764B6D"); + ContentService.Save(ContentActs2022, -1); + + ContentAct2022RanD = ContentBuilder.CreateSimpleContent(ContentTypeAct, "Ran-D", ContentActs2022.Id); + ContentAct2022RanD.Key = new Guid("9BE4C615-240E-4616-BB65-C1F2DE9C3873"); + ContentService.Save(ContentAct2022RanD, -1); + + ContentStages2022 = ContentBuilder.CreateSimpleContent(ContentTypeStages, "Stages", ContentYear2022.Id); + ContentStages2022.Key = new Guid("1FF59D2F-FCE8-455B-98A6-7686BF41FD33"); + ContentService.Save(ContentStages2022, -1); + + ContentStage2022Red = ContentBuilder.CreateSimpleContent(ContentTypeStage, "Red", ContentStages2022.Id); + ContentStage2022Red.Key = new Guid("F1C4E4D6-FFDE-4053-9240-EC594CE2A073"); + ContentService.Save(ContentStage2022Red, -1); + + ContentStage2022Blue = ContentBuilder.CreateSimpleContent(ContentTypeStage, "Blue", ContentStages2022.Id); + ContentStage2022Blue.Key = new Guid("085311BB-2E75-4FB3-AC30-05F8CF2D3CB5"); + ContentService.Save(ContentStage2022Blue, -1); + + ContentYear2023 = ContentBuilder.CreateSimpleContent(ContentTypeYear, "2023", ContentYears.Id); + ContentYear2023.Key = new Guid("2A863C61-8422-4863-8818-795711FFF0FC"); + ContentService.Save(ContentYear2023, -1); + + ContentYear2024 = ContentBuilder.CreateSimpleContent(ContentTypeYear, "2024", ContentYears.Id); + ContentYear2024.Key = new Guid("E547A970-3923-4EF0-9EDA-10CB83FF038F"); + ContentService.Save(ContentYear2024, -1); + + Trashed = ContentBuilder.CreateSimpleContent(ContentTypeYears, "Text Page Deleted", -20); + Trashed.Trashed = true; + ContentService.Save(Trashed, -1); + } + + + [Test] + public async Task GetDynamicRoots__With_NearestAncestorOrSelf_and_filter_of_own_doc_type_should_return_self() + { + // Arrange + var startNodeSelector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Current.ToString(), + OriginKey = null, + Context = + new DynamicRootContext() { CurrentKey = ContentAct2022RanD.Key, ParentKey = ContentActs2022.Key }, + QuerySteps = new DynamicRootQueryStep[] + { + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.NearestAncestorOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentAct2022RanD.ContentType.Key }, + }, + }, + }; + + // Act + var result = (await DynamicRootService.GetDynamicRootsAsync(startNodeSelector)).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.AreEqual(1, result.Count()); + CollectionAssert.Contains(result, startNodeSelector.Context.CurrentKey.Value); + }); + } + + [Test] + public async Task GetDynamicRoots__With_NearestAncestorOrSelf_and_origin_root_should_return_empty_list() + { + // Arrange + var startNodeSelector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Root.ToString(), + OriginKey = null, + Context = + new DynamicRootContext() { CurrentKey = ContentAct2022RanD.Key, ParentKey = ContentActs2022.Key }, + QuerySteps = new DynamicRootQueryStep[] + { + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.NearestAncestorOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentAct2022RanD.ContentType.Key }, + }, + }, + }; + + // Act + var result = await DynamicRootService.GetDynamicRootsAsync(startNodeSelector); + + // Assert + Assert.Multiple(() => + { + Assert.AreEqual(0, result.Count()); + }); + } + + [Test] + [TestCase(DynamicRootStepAlias.NearestDescendantOrSelf)] + [TestCase(DynamicRootStepAlias.FarthestDescendantOrSelf)] + public async Task + GetDynamicRoots__DescendantOrSelf_must_handle_when_there_is_not_found_any_and_level_becomes_impossible_to_get( + DynamicRootStepAlias dynamicRootAlias) + { + // Arrange + var startNodeSelector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Current.ToString(), + OriginKey = null, + Context = new DynamicRootContext() + { + CurrentKey = ContentAct2022RanD.Key, ParentKey = ContentActs2022.Key, + }, + QuerySteps = new DynamicRootQueryStep[] + { + new DynamicRootQueryStep() + { + Alias = dynamicRootAlias.ToString(), AnyOfDocTypeKeys = new[] { Guid.NewGuid() } + }, + }, + }; + + // Act + var result = await DynamicRootService.GetDynamicRootsAsync(startNodeSelector); + + // Assert + Assert.Multiple(() => + { + Assert.AreEqual(0, result.Count()); + }); + } + + [Test] + public async Task GetDynamicRoots__NearestDescendantOrSelf__has_to_find_only_the_nearest() + { + // Arrange + + // Allow atc to add acts + ContentTypeAct.AllowedContentTypes = + ContentTypeAct.AllowedContentTypes!.Union(new ContentTypeSort[] + { + new ContentTypeSort(ContentTypeActs.Id, 0), + }); + ContentTypeService.Save(ContentTypeAct); + + var contentNewActs = ContentBuilder.CreateSimpleContent(ContentTypeActs, "new Acts", ContentAct2022RanD.Id); + contentNewActs.Key = new Guid("EA309F8C-8F1A-4C19-9613-2F950CDDCB8D"); + ContentService.Save(contentNewActs, -1); + + var contentNewAct = + ContentBuilder.CreateSimpleContent(ContentTypeAct, "new act under new acts", contentNewActs.Id); + contentNewAct.Key = new Guid("7E14BA13-C998-46DE-92AE-8E1C18CCEE02"); + ContentService.Save(contentNewAct, -1); + + + var startNodeSelector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Root.ToString(), + OriginKey = null, + Context = new DynamicRootContext() { CurrentKey = contentNewAct.Key, ParentKey = contentNewActs.Key }, + QuerySteps = new[] + { + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.NearestDescendantOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentTypeActs.Key }, + }, + }, + }; + + // Act + var result = (await DynamicRootService.GetDynamicRootsAsync(startNodeSelector)).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.AreEqual(1, result.Count()); + CollectionAssert.Contains(result, ContentActs2022.Key); + }); + } + + [Test] + public async Task GetDynamicRoots__FarthestDescendantOrSelf__has_to_find_only_the_farthest() + { + // Arrange + + // Allow act to add acts + ContentTypeAct.AllowedContentTypes = + ContentTypeAct.AllowedContentTypes!.Union(new[] { new ContentTypeSort(ContentTypeActs.Id, 0) }); + ContentTypeService.Save(ContentTypeAct); + + var contentNewActs = ContentBuilder.CreateSimpleContent(ContentTypeActs, "new Acts", ContentAct2022RanD.Id); + contentNewActs.Key = new Guid("EA309F8C-8F1A-4C19-9613-2F950CDDCB8D"); + ContentService.Save(contentNewActs, -1); + + var contentNewAct = + ContentBuilder.CreateSimpleContent(ContentTypeAct, "new act under new acts", contentNewActs.Id); + contentNewAct.Key = new Guid("7E14BA13-C998-46DE-92AE-8E1C18CCEE02"); + ContentService.Save(contentNewAct, -1); + + + var startNodeSelector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Root.ToString(), + OriginKey = null, + Context = new DynamicRootContext() { CurrentKey = contentNewAct.Key, ParentKey = contentNewActs.Key }, + QuerySteps = new[] + { + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.FarthestDescendantOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentTypeActs.Key }, + }, + }, + }; + + // Act + var result = (await DynamicRootService.GetDynamicRootsAsync(startNodeSelector)).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.AreEqual(1, result.Count()); + CollectionAssert.Contains(result, contentNewActs.Key); + }); + } + + [Test] + public async Task GetDynamicRoots__With_multiple_filters() + { + // Arrange + var startNodeSelector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Current.ToString(), + OriginKey = null, + Context = + new DynamicRootContext() { CurrentKey = ContentAct2022RanD.Key, ParentKey = ContentActs2022.Key }, + QuerySteps = new[] + { + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.NearestAncestorOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentTypeYear.Key }, + }, + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.NearestDescendantOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentTypeStages.Key }, + }, + }, + }; + + // Act + var result = (await DynamicRootService.GetDynamicRootsAsync(startNodeSelector)).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.AreEqual(1, result.Count()); + CollectionAssert.Contains(result, ContentStages2022.Key); + }); + } + + [Test] + public async Task GetDynamicRoots__With_NearestDescendantOrSelf_and_filter_of_own_doc_type_should_return_self() + { + // Arrange + var startNodeSelector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Current.ToString(), + OriginKey = null, + Context = new DynamicRootContext() { CurrentKey = ContentYear2022.Key, ParentKey = ContentYears.Key }, + QuerySteps = new DynamicRootQueryStep[] + { + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.NearestDescendantOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentYear2022.ContentType.Key }, + }, + }, + }; + + // Act + var result = (await DynamicRootService.GetDynamicRootsAsync(startNodeSelector)).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.AreEqual(1, result.Count()); + CollectionAssert.Contains(result, startNodeSelector.Context.CurrentKey.Value); + }); + } + + + [Test] + public async Task GetDynamicRoots__With_no_filters_should_return_what_origin_finds() + { + // Arrange + var startNodeSelector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Parent.ToString(), + OriginKey = null, + Context = new DynamicRootContext() { CurrentKey = ContentYear2022.Key, ParentKey = ContentYears.Key }, + QuerySteps = Array.Empty(), + }; + + // Act + var result = (await DynamicRootService.GetDynamicRootsAsync(startNodeSelector)).ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.AreEqual(1, result.Count()); + CollectionAssert.Contains(result, startNodeSelector.Context.ParentKey); + }); + } + + + [Test] + public void CalculateOriginKey__Parent_should_just_return_the_parent_key() + { + // Arrange + var selector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Parent.ToString(), + OriginKey = null, + Context = new DynamicRootContext() { CurrentKey = ContentYear2022.Key, ParentKey = ContentYears.Key }, + }; + + // Act + var result = DynamicRootService.FindOriginKey(selector); + + // Assert + Assert.AreEqual(selector.Context.ParentKey, result); + } + + [Test] + public void CalculateOriginKey__Current_should_just_return_the_current_key_when_it_exists() + { + // Arrange + var selector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Current.ToString(), + OriginKey = null, + Context = new DynamicRootContext() { CurrentKey = ContentYear2022.Key, ParentKey = ContentYears.Key }, + }; + + // Act + var result = DynamicRootService.FindOriginKey(selector); + + // Assert + Assert.AreEqual(selector.Context.CurrentKey, result); + } + + [Test] + public void CalculateOriginKey__Current_should_just_return_null_when_it_does_not_exist() + { + // Arrange + var selector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Current.ToString(), + OriginKey = null, + Context = new DynamicRootContext() { CurrentKey = Guid.NewGuid(), ParentKey = ContentYears.Key }, + }; + + // Act + var result = DynamicRootService.FindOriginKey(selector); + + // Assert + Assert.IsNull(result); + } + + [Test] + public void CalculateOriginKey__Root_should_traverse_the_path_and_take_the_first_level_in_the_root() + { + // Arrange + var selector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Root.ToString(), + OriginKey = null, + Context = new DynamicRootContext() + { + CurrentKey = ContentAct2022RanD.Key, ParentKey = ContentActs2022.Key, + }, + }; + + // Act + var result = DynamicRootService.FindOriginKey(selector); + + // Assert + Assert.AreEqual(ContentYears.Key, result); + } + + [Test] + public void CalculateOriginKey__Site_should_return_the_first_with_an_assigned_domain_also_it_self() + { + // Arrange + var origin = ContentYear2022; + var selector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Site.ToString(), + OriginKey = origin.Key, + Context = new DynamicRootContext() { CurrentKey = origin.Key, ParentKey = ContentYears.Key }, + }; + + DomainService.Save( + new UmbracoDomain("http://test.umbraco.com") { RootContentId = origin.Id, LanguageIsoCode = "en-us" }); + + // Act + var result = DynamicRootService.FindOriginKey(selector); + + // Assert + Assert.AreEqual(origin.Key, result); + } + + [Test] + public void CalculateOriginKey__Site_should_return_the_first_with_an_assigned_domain() + { + // Arrange + var origin = ContentAct2022RanD; + var selector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Site.ToString(), + OriginKey = origin.Key, + Context = new DynamicRootContext() { CurrentKey = origin.Key, ParentKey = ContentActs2022.Key }, + }; + + DomainService.Save(new UmbracoDomain("http://test.umbraco.com") + { + RootContentId = ContentYears.Id, + LanguageIsoCode = "en-us", + }); + + // Act + var result = DynamicRootService.FindOriginKey(selector); + + // Assert + Assert.AreEqual(ContentYears.Key, result); + } + + [Test] + public void CalculateOriginKey__Site_should_fallback_to_root_when_no_domain_is_assigned() + { + // Arrange + var selector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Site.ToString(), + OriginKey = ContentActs2022.Key, + Context = new DynamicRootContext() + { + CurrentKey = ContentAct2022RanD.Key, + ParentKey = ContentActs2022.Key, + }, + }; + + // Act + var result = DynamicRootService.FindOriginKey(selector); + + // Assert + Assert.AreEqual(ContentYears.Key, result); + } + + [Test] + [TestCase(DynamicRootOrigin.ByKey)] + [TestCase(DynamicRootOrigin.Parent)] + [TestCase(DynamicRootOrigin.Root)] + [TestCase(DynamicRootOrigin.Site)] + [TestCase(DynamicRootOrigin.Site)] + public void CalculateOriginKey__with_a_random_key_should_return_null(DynamicRootOrigin origin) + { + // Arrange + var randomKey = Guid.NewGuid(); + var selector = new DynamicRootNodeQuery() + { + OriginAlias = origin.ToString(), + OriginKey = randomKey, + Context = new DynamicRootContext() { CurrentKey = randomKey, ParentKey = Guid.NewGuid() }, + }; + + // Act + var result = DynamicRootService.FindOriginKey(selector); + + // Assert + Assert.IsNull(result); + } + + [Test] + [TestCase(DynamicRootOrigin.ByKey)] + [TestCase(DynamicRootOrigin.Parent)] + [TestCase(DynamicRootOrigin.Root)] + [TestCase(DynamicRootOrigin.Site)] + [TestCase(DynamicRootOrigin.Current)] + public void CalculateOriginKey__with_a_trashed_key_should_still_be_allowed(DynamicRootOrigin origin) + { + // Arrange + var trashedKey = Trashed.Key; + var selector = new DynamicRootNodeQuery() + { + OriginAlias = origin.ToString(), + OriginKey = trashedKey, + Context = new DynamicRootContext() { CurrentKey = trashedKey, ParentKey = trashedKey }, + }; + + // Act + var result = DynamicRootService.FindOriginKey(selector); + + // Assert + Assert.IsNotNull(result); + } + + [Test] + [TestCase(DynamicRootOrigin.ByKey)] + [TestCase(DynamicRootOrigin.Parent)] + [TestCase(DynamicRootOrigin.Root)] + [TestCase(DynamicRootOrigin.Site)] + [TestCase(DynamicRootOrigin.Current)] + public void CalculateOriginKey__with_a_ContentType_key_should_return_null(DynamicRootOrigin origin) + { + // Arrange + var contentTypeKey = ContentTypeYears.Key; + var selector = new DynamicRootNodeQuery() + { + OriginAlias = origin.ToString(), + OriginKey = contentTypeKey, + Context = new DynamicRootContext() { CurrentKey = contentTypeKey, ParentKey = contentTypeKey } + }; + + // Act + var result = DynamicRootService.FindOriginKey(selector); + + // Assert + Assert.IsNull(result); + } + + [Test] + public async Task GetDynamicRoots__With_multiple_filters_that_do_not_return_any_results() + { + // Arrange + var startNodeSelector = new DynamicRootNodeQuery() + { + OriginAlias = DynamicRootOrigin.Current.ToString(), + OriginKey = null, + Context = + new DynamicRootContext() { CurrentKey = ContentAct2022RanD.Key, ParentKey = ContentActs2022.Key }, + QuerySteps = new[] + { + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.NearestAncestorOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentTypeYear.Key }, + }, + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.NearestDescendantOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentTypeStages.Key }, + }, + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.NearestDescendantOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentTypeYears.Key }, + }, + new DynamicRootQueryStep() + { + Alias = DynamicRootStepAlias.NearestDescendantOrSelf.ToString(), + AnyOfDocTypeKeys = new[] { ContentTypeYears.Key }, + }, + }, + }; + + // Act + var result = (await DynamicRootService.GetDynamicRootsAsync(startNodeSelector)).ToList(); + + // Assert + Assert.AreEqual(0, result.Count()); + } +}