From 7c9c7337b98514f4f605ee6823b81a2ed7467cee Mon Sep 17 00:00:00 2001 From: NguyenThuyLan <116753400+NguyenThuyLan@users.noreply.github.com> Date: Mon, 4 Aug 2025 17:44:56 +0700 Subject: [PATCH 01/39] Add pagination and total to examine dashboard (#19847) * Add pagination and total to examine dashboard * fix name and localization --------- Co-authored-by: Lan Nguyen Thuy --- .../pagination-manager/pagination.manager.ts | 18 +++++++ .../views/section-view-examine-searchers.ts | 54 +++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination-manager/pagination.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination-manager/pagination.manager.ts index 9c1db36e9c..508d233482 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination-manager/pagination.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination-manager/pagination.manager.ts @@ -138,4 +138,22 @@ export class UmbPaginationManager extends EventTarget { const skip = Math.max(0, (this.#currentPage.getValue() - 1) * this.#pageSize.getValue()); this.#skip.setValue(skip); } + + /** + * Gets the index of the first item on the current page (for display). + * @returns {number} + * @memberof UmbPaginationManager + */ + public getDisplayStart(): number { + return this.getSkip() + 1; + } + + /** + * Gets the index of the last item on the current page (for display). + * @returns {number} + * @memberof UmbPaginationManager + */ + public getDisplayEnd(): number { + return Math.min(this.getSkip() + this.getPageSize(), this.getTotalItems()); + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/search/examine-management-dashboard/views/section-view-examine-searchers.ts b/src/Umbraco.Web.UI.Client/src/packages/search/examine-management-dashboard/views/section-view-examine-searchers.ts index 3e31005701..684bc19d31 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/search/examine-management-dashboard/views/section-view-examine-searchers.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/search/examine-management-dashboard/views/section-view-examine-searchers.ts @@ -8,6 +8,8 @@ import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/rou import { SearcherService } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbLitElement, umbFocus } from '@umbraco-cms/backoffice/lit-element'; import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import { UmbPaginationManager } from '@umbraco-cms/backoffice/utils'; +import type { UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; interface ExposedSearchResultField { name: string; @@ -34,6 +36,17 @@ export class UmbDashboardExamineSearcherElement extends UmbLitElement { @state() private _workspacePath = 'aa'; + @state() + _totalPages = 1; + + @state() + _currentPage = 1; + + @state() + _totalNumberOfResults = 0; + + #paginationManager = new UmbPaginationManager(); + private _onKeyPress(e: KeyboardEvent) { if (e.key == 'Enter') { this._onSearch(); @@ -44,6 +57,12 @@ export class UmbDashboardExamineSearcherElement extends UmbLitElement { constructor() { super(); + + this.#paginationManager.setPageSize(100); + + this.observe(this.#paginationManager.currentPage, (number) => (this._currentPage = number)); + this.observe(this.#paginationManager.totalPages, (number) => (this._totalPages = number)); + new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL) .addAdditionalPath(':entityType') .onSetup((routingInfo) => { @@ -64,13 +83,15 @@ export class UmbDashboardExamineSearcherElement extends UmbLitElement { path: { searcherName: this.searcherName }, query: { term: this._searchInput.value, - take: 100, - skip: 0, + take: this.#paginationManager.getPageSize(), + skip: this.#paginationManager.getSkip(), }, }), ); this._searchResults = data?.items ?? []; + this.#paginationManager.setTotalItems(data.total); + this._totalNumberOfResults = data.total; this._updateFieldFilter(); this._searchLoading = false; } @@ -158,13 +179,29 @@ export class UmbDashboardExamineSearcherElement extends UmbLitElement { } } + #onPageChange(event: UUIPaginationEvent) { + this.#paginationManager.setCurrentPageNumber(event.target?.current); + this._onSearch(); + } + private renderSearchResults() { if (this._searchLoading) return html``; if (!this._searchResults) return nothing; if (!this._searchResults.length) { return html`

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

`; } - return html`
+ return html` +
+ ${this.localize.term( + 'examineManagement_searchResultsFound', + this.#paginationManager.getDisplayStart(), + this.#paginationManager.getDisplayEnd(), + this._totalNumberOfResults, + this._currentPage, + this._totalPages, + )} +
+
@@ -212,7 +249,16 @@ export class UmbDashboardExamineSearcherElement extends UmbLitElement { -
`; +
+ + `; } renderHeadCells() { From 4bf2fbf1baa665c85b4c84a38a769023d8884b30 Mon Sep 17 00:00:00 2001 From: Dirk Seefeld Date: Mon, 4 Aug 2025 12:56:25 +0200 Subject: [PATCH 02/39] Bugfix/19601 can not add ef core migrations (#19846) * fix EFCore add migration issue * update test * Resolved breaking changes and code review comments. * Removed extra line break. --------- Co-authored-by: Andy Butland --- .../Composition/UmbracoEFCoreComposer.cs | 2 +- ...mbracoEFCoreServiceCollectionExtensions.cs | 67 +++++++++++++++---- .../UmbracoDbContext.cs | 40 ++++++----- .../CustomDbContextUmbracoProviderTests.cs | 4 +- 4 files changed, 80 insertions(+), 33 deletions(-) diff --git a/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs b/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs index 245bbe5534..08353e8d02 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs @@ -19,7 +19,7 @@ public class UmbracoEFCoreComposer : IComposer builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); - builder.Services.AddUmbracoDbContext((options) => + builder.Services.AddUmbracoDbContext((provider, options, connectionString, providerName) => { // Register the entity sets needed by OpenIddict. options.UseOpenIddict(); diff --git a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs index d7da8a65fe..adcfb27406 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs @@ -17,32 +17,50 @@ public static class UmbracoEFCoreServiceCollectionExtensions /// /// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes. /// - /// - /// - /// - /// - public static IServiceCollection AddUmbracoDbContext(this IServiceCollection services, Action? optionsAction = null) + [Obsolete("Please use the method overload that takes all parameters for the optionsAction. Scheduled for removal in Umbraco 18.")] + public static IServiceCollection AddUmbracoDbContext( + this IServiceCollection services, + Action? optionsAction = null) + where T : DbContext + => AddUmbracoDbContext(services, (sp, optionsBuilder, connectionString, providerName) => optionsAction?.Invoke(optionsBuilder)); + + /// + /// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes. + /// + public static IServiceCollection AddUmbracoDbContext( + this IServiceCollection services, + Action? optionsAction = null) where T : DbContext { - return AddUmbracoDbContext(services, (IServiceProvider _, DbContextOptionsBuilder options) => + return AddUmbracoDbContext(services, (IServiceProvider provider, DbContextOptionsBuilder optionsBuilder, string? providerName, string? connectionString) => { - optionsAction?.Invoke(options); + ConnectionStrings connectionStrings = GetConnectionStringAndProviderName(provider); + optionsAction?.Invoke(optionsBuilder, connectionStrings.ConnectionString, connectionStrings.ProviderName, provider); }); } /// /// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes. /// - /// - /// - /// - /// - public static IServiceCollection AddUmbracoDbContext(this IServiceCollection services, Action? optionsAction = null) + [Obsolete("Please use the method overload that takes all parameters for the optionsAction. Scheduled for removal in Umbraco 18.")] + public static IServiceCollection AddUmbracoDbContext( + this IServiceCollection services, + Action? optionsAction = null) + where T : DbContext + => AddUmbracoDbContext(services, (sp, optionsBuilder, connectionString, providerName) => optionsAction?.Invoke(sp, optionsBuilder)); + + /// + /// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes. + /// + public static IServiceCollection AddUmbracoDbContext( + this IServiceCollection services, + Action? optionsAction = null) where T : DbContext { - optionsAction ??= (sp, options) => { }; + optionsAction ??= (sp, optionsBuilder, connectionString, providerName) => { }; - services.AddPooledDbContextFactory(optionsAction); + + services.AddPooledDbContextFactory((provider, optionsBuilder) => SetupDbContext(optionsAction, provider, optionsBuilder)); services.AddTransient(services => services.GetRequiredService>().CreateDbContext()); services.AddUnique, AmbientEFCoreScopeStack>(); @@ -110,4 +128,25 @@ public static class UmbracoEFCoreServiceCollectionExtensions builder.UseDatabaseProvider(connectionStrings.ProviderName, connectionStrings.ConnectionString); } + + private static void SetupDbContext(Action? optionsAction, IServiceProvider provider, DbContextOptionsBuilder builder) + { + ConnectionStrings connectionStrings = GetConnectionStringAndProviderName(provider); + + optionsAction?.Invoke(provider, builder, connectionStrings.ConnectionString, connectionStrings.ProviderName); + } + + private static ConnectionStrings GetConnectionStringAndProviderName(IServiceProvider serviceProvider) + { + ConnectionStrings connectionStrings = serviceProvider.GetRequiredService>().CurrentValue; + + // Replace data directory + string? dataDirectory = AppDomain.CurrentDomain.GetData(Constants.System.DataDirectoryName)?.ToString(); + if (string.IsNullOrEmpty(dataDirectory) is false) + { + connectionStrings.ConnectionString = connectionStrings.ConnectionString?.Replace(Constants.System.DataDirectoryPlaceholder, dataDirectory); + } + + return connectionStrings; + } } diff --git a/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs b/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs index 9fcaccfe37..ca69e31727 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs @@ -1,4 +1,5 @@ using System.Configuration; +using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.DependencyInjection; @@ -17,14 +18,14 @@ namespace Umbraco.Cms.Persistence.EFCore; /// and insure the 'src/Umbraco.Web.UI/appsettings.json' have a connection string set with the right provider. /// /// Create a migration for each provider. -/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer -c UmbracoDbContext -- --provider SqlServer +/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer -c UmbracoDbContext /// -/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite -c UmbracoDbContext -- --provider Sqlite +/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite -c UmbracoDbContext /// /// Remove the last migration for each provider. -/// dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer -- --provider SqlServer +/// dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer /// -/// dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite -- --provider Sqlite +/// dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite /// /// To find documentation about this way of working with the context see /// https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/providers?tabs=dotnet-core-cli#using-one-context-type @@ -37,28 +38,35 @@ public class UmbracoDbContext : DbContext /// public UmbracoDbContext(DbContextOptions options) : base(ConfigureOptions(options)) - { - - } + { } private static DbContextOptions ConfigureOptions(DbContextOptions options) { - IOptionsMonitor connectionStringsOptionsMonitor = StaticServiceProvider.Instance.GetRequiredService>(); - - ConnectionStrings connectionStrings = connectionStringsOptionsMonitor.CurrentValue; - - if (string.IsNullOrWhiteSpace(connectionStrings.ConnectionString)) + var extensions = options.Extensions.FirstOrDefault() as Microsoft.EntityFrameworkCore.Infrastructure.CoreOptionsExtension; + IServiceProvider? serviceProvider = extensions?.ApplicationServiceProvider; + serviceProvider ??= StaticServiceProvider.Instance; + if (serviceProvider == null) { - ILogger logger = StaticServiceProvider.Instance.GetRequiredService>(); - logger.LogCritical("No connection string was found, cannot setup Umbraco EF Core context"); + // If the service provider is null, we cannot resolve the connection string or migration provider. + throw new InvalidOperationException("The service provider is not configured. Ensure that UmbracoDbContext is registered correctly."); + } + + IOptionsMonitor? connectionStringsOptionsMonitor = serviceProvider?.GetRequiredService>(); + + ConnectionStrings? connectionStrings = connectionStringsOptionsMonitor?.CurrentValue; + + if (string.IsNullOrWhiteSpace(connectionStrings?.ConnectionString)) + { + ILogger? logger = serviceProvider?.GetRequiredService>(); + logger?.LogCritical("No connection string was found, cannot setup Umbraco EF Core context"); // we're throwing an exception here to make it abundantly clear that one should never utilize (or have a // dependency on) the DbContext before the connection string has been initialized by the installer. throw new InvalidOperationException("No connection string was found, cannot setup Umbraco EF Core context"); } - IEnumerable migrationProviders = StaticServiceProvider.Instance.GetServices(); - IMigrationProviderSetup? migrationProvider = migrationProviders.FirstOrDefault(x => x.ProviderName.CompareProviderNames(connectionStrings.ProviderName)); + IEnumerable? migrationProviders = serviceProvider?.GetServices(); + IMigrationProviderSetup? migrationProvider = migrationProviders?.FirstOrDefault(x => x.ProviderName.CompareProviderNames(connectionStrings.ProviderName)); if (migrationProvider == null && connectionStrings.ProviderName != null) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/CustomDbContextUmbracoProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/CustomDbContextUmbracoProviderTests.cs index 4a7c64ea5c..d934f0286d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/CustomDbContextUmbracoProviderTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/CustomDbContextUmbracoProviderTests.cs @@ -22,7 +22,7 @@ internal sealed class CustomDbContextUmbracoProviderTests : UmbracoIntegrationTe protected override void CustomTestSetup(IUmbracoBuilder builder) { - builder.Services.AddUmbracoDbContext((serviceProvider, options) => + builder.Services.AddUmbracoDbContext((serviceProvider, options, connectionString, providerName) => { options.UseUmbracoDatabaseProvider(serviceProvider); }); @@ -53,7 +53,7 @@ public class CustomDbContextCustomSqliteProviderTests : UmbracoIntegrationTest protected override void CustomTestSetup(IUmbracoBuilder builder) { - builder.Services.AddUmbracoDbContext((serviceProvider, options) => + builder.Services.AddUmbracoDbContext((serviceProvider, options, connectionString, providerName) => { options.UseSqlite("Data Source=:memory:;Version=3;New=True;"); }); From af8742651cdbe8be7163723a1c8d985879568299 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 4 Aug 2025 13:56:37 +0200 Subject: [PATCH 03/39] Umbraco Engage UmbracoUrlAlias Fix - Fixes #19654 (#19827) (#19850) * Fixes #19654 Adds the propertyAlias to the VariationContext so that products implementing the GetSegment method are aware which propertyAlias it's being called for * Re-implement original variation context for backwards compatibility * Fixes hidden overload method Ensures the `GetSegment` method overload is not hidden when a null `propertyAlias` is passed. * Resolve backward compatibility issues. * Improved comments. --------- # Conflicts: # src/Umbraco.PublishedCache.NuCache/Property.cs --- .../PublishedValueFallback.cs | 9 ++-- .../PublishedContent/VariationContext.cs | 12 ++++-- .../VariationContextAccessorExtensions.cs | 42 +++++++++++++++---- .../PublishedProperty.cs | 14 ++++--- 4 files changed, 55 insertions(+), 22 deletions(-) diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs index 3360b0e78a..368e4a99ee 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Extensions; @@ -31,7 +30,7 @@ public class PublishedValueFallback : IPublishedValueFallback /// public bool TryGetValue(IPublishedProperty property, string? culture, string? segment, Fallback fallback, T? defaultValue, out T? value) { - _variationContextAccessor.ContextualizeVariation(property.PropertyType.Variations, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(property.PropertyType.Variations, property.Alias, ref culture, ref segment); foreach (var f in fallback) { @@ -79,7 +78,7 @@ public class PublishedValueFallback : IPublishedValueFallback return false; } - _variationContextAccessor.ContextualizeVariation(propertyType.Variations, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(propertyType.Variations, alias, ref culture, ref segment); foreach (var f in fallback) { @@ -125,7 +124,7 @@ public class PublishedValueFallback : IPublishedValueFallback IPublishedPropertyType? propertyType = content.ContentType.GetPropertyType(alias); if (propertyType != null) { - _variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, alias, ref culture, ref segment); noValueProperty = content.GetProperty(alias); } @@ -196,7 +195,7 @@ public class PublishedValueFallback : IPublishedValueFallback { culture = null; segment = null; - _variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, alias, ref culture, ref segment); } property = content?.GetProperty(alias); diff --git a/src/Umbraco.Core/Models/PublishedContent/VariationContext.cs b/src/Umbraco.Core/Models/PublishedContent/VariationContext.cs index 92326ae359..395e0f9c20 100644 --- a/src/Umbraco.Core/Models/PublishedContent/VariationContext.cs +++ b/src/Umbraco.Core/Models/PublishedContent/VariationContext.cs @@ -25,9 +25,15 @@ public class VariationContext public string Segment { get; } /// - /// Gets the segment for the content item + /// Gets the segment for the content item. /// - /// - /// + /// The content Id. public virtual string GetSegment(int contentId) => Segment; + + /// + /// Gets the segment for the content item and property alias. + /// + /// The content Id. + /// The property alias. + public virtual string GetSegment(int contentId, string propertyAlias) => Segment; } diff --git a/src/Umbraco.Core/Models/PublishedContent/VariationContextAccessorExtensions.cs b/src/Umbraco.Core/Models/PublishedContent/VariationContextAccessorExtensions.cs index e8f6e3bdc1..566d5e45af 100644 --- a/src/Umbraco.Core/Models/PublishedContent/VariationContextAccessorExtensions.cs +++ b/src/Umbraco.Core/Models/PublishedContent/VariationContextAccessorExtensions.cs @@ -8,25 +8,45 @@ namespace Umbraco.Extensions; public static class VariationContextAccessorExtensions { + [Obsolete("Please use the method overload that accepts all parameters. Scheduled for removal in Umbraco 18.")] public static void ContextualizeVariation( this IVariationContextAccessor variationContextAccessor, ContentVariation variations, ref string? culture, ref string? segment) - => variationContextAccessor.ContextualizeVariation(variations, null, ref culture, ref segment); + => variationContextAccessor.ContextualizeVariation(variations, null, null, ref culture, ref segment); + public static void ContextualizeVariation( + this IVariationContextAccessor variationContextAccessor, + ContentVariation variations, + string? propertyAlias, + ref string? culture, + ref string? segment) + => variationContextAccessor.ContextualizeVariation(variations, null, propertyAlias, ref culture, ref segment); + + [Obsolete("Please use the method overload that accepts all parameters. Scheduled for removal in Umbraco 18.")] public static void ContextualizeVariation( this IVariationContextAccessor variationContextAccessor, ContentVariation variations, int contentId, ref string? culture, ref string? segment) - => variationContextAccessor.ContextualizeVariation(variations, (int?)contentId, ref culture, ref segment); + => variationContextAccessor.ContextualizeVariation(variations, (int?)contentId, null, ref culture, ref segment); + + public static void ContextualizeVariation( + this IVariationContextAccessor variationContextAccessor, + ContentVariation variations, + int contentId, + string? propertyAlias, + ref string? culture, + ref string? segment) + => variationContextAccessor.ContextualizeVariation(variations, (int?)contentId, propertyAlias, ref culture, ref segment); private static void ContextualizeVariation( this IVariationContextAccessor variationContextAccessor, ContentVariation variations, int? contentId, + string? propertyAlias, ref string? culture, ref string? segment) { @@ -37,18 +57,22 @@ public static class VariationContextAccessorExtensions // use context values VariationContext? publishedVariationContext = variationContextAccessor?.VariationContext; - if (culture == null) - { - culture = variations.VariesByCulture() ? publishedVariationContext?.Culture : string.Empty; - } + culture ??= variations.VariesByCulture() ? publishedVariationContext?.Culture : string.Empty; if (segment == null) { if (variations.VariesBySegment()) { - segment = contentId == null - ? publishedVariationContext?.Segment - : publishedVariationContext?.GetSegment(contentId.Value); + if (contentId == null) + { + segment = publishedVariationContext?.Segment; + } + else + { + segment = propertyAlias == null ? + publishedVariationContext?.GetSegment(contentId.Value) : + publishedVariationContext?.GetSegment(contentId.Value, propertyAlias); + } } else { diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs index 167e46c253..83f063a2b4 100644 --- a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Collections; using Umbraco.Cms.Core.Models; @@ -21,6 +21,8 @@ internal sealed class PublishedProperty : PublishedPropertyBase private readonly ContentVariation _variations; private readonly ContentVariation _sourceVariations; + private readonly string _propertyTypeAlias; + // the variant and non-variant object values private bool _interInitialized; private object? _interValue; @@ -71,6 +73,8 @@ internal sealed class PublishedProperty : PublishedPropertyBase // it must be set to the union of variance (the combination of content type and property type variance). _variations = propertyType.Variations | content.ContentType.Variations; _sourceVariations = propertyType.Variations; + + _propertyTypeAlias = propertyType.Alias; } // used to cache the CacheValues of this property @@ -89,7 +93,7 @@ internal sealed class PublishedProperty : PublishedPropertyBase // determines whether a property has value public override bool HasValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); var value = GetSourceValue(culture, segment); var hasValue = PropertyType.IsValue(value, PropertyValueLevel.Source); @@ -103,7 +107,7 @@ internal sealed class PublishedProperty : PublishedPropertyBase public override object? GetSourceValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_sourceVariations, _content.Id, ref culture, ref segment); + _content.VariationContextAccessor.ContextualizeVariation(_sourceVariations, _content.Id, _propertyTypeAlias, ref culture, ref segment); // source values are tightly bound to the property/schema culture and segment configurations, so we need to // sanitize the contextualized culture/segment states before using them to access the source values. @@ -146,7 +150,7 @@ internal sealed class PublishedProperty : PublishedPropertyBase public override object? GetValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); object? value; CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment); @@ -209,7 +213,7 @@ internal sealed class PublishedProperty : PublishedPropertyBase public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); object? value; CacheValue cacheValues = GetCacheValues(expanding ? PropertyType.DeliveryApiCacheLevelForExpansion : PropertyType.DeliveryApiCacheLevel).For(culture, segment); From c31861945cc527ac454b22171ddef74bc0ac3a8b Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:04:42 +0200 Subject: [PATCH 04/39] update package lockfile --- src/Umbraco.Web.UI.Client/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 4bb032625a..c07b2f28d3 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -1,12 +1,12 @@ { "name": "@umbraco-cms/backoffice", - "version": "16.1.0-rc", + "version": "16.2.0-rc", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@umbraco-cms/backoffice", - "version": "16.1.0-rc", + "version": "16.2.0-rc", "license": "MIT", "workspaces": [ "./src/packages/*", From f5ff2bbf592cda23c4d0bd38beead3f8b4dca28d Mon Sep 17 00:00:00 2001 From: NguyenThuyLan <116753400+NguyenThuyLan@users.noreply.github.com> Date: Tue, 5 Aug 2025 12:35:46 +0700 Subject: [PATCH 05/39] Fix issue create media folder throw error (#19854) Co-authored-by: Lan Nguyen Thuy --- .../info-app/media-history-workspace-info-app.element.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/audit-log/info-app/media-history-workspace-info-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/audit-log/info-app/media-history-workspace-info-app.element.ts index 711a2782be..8b55dd976a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/audit-log/info-app/media-history-workspace-info-app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/audit-log/info-app/media-history-workspace-info-app.element.ts @@ -42,7 +42,9 @@ export class UmbMediaHistoryWorkspaceInfoAppElement extends UmbLitElement { } async #requestAuditLogs() { - const unique = this.#workspaceContext?.getUnique(); + if (!this.#workspaceContext) return; + + const unique = this.#workspaceContext.getUnique(); if (!unique) throw new Error('Media unique is required'); const { data } = await this.#auditLogRepository.requestAuditLog({ From 20254f0bbc11d749239554876b2f97e82eb05c7c Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 5 Aug 2025 09:53:39 +0200 Subject: [PATCH 06/39] Added user start node restrictions to sibling endpoints (#19839) * Added user start node restrictions to sibling endpoints. * Further integration tests. * Tidy up. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Revert previous update. * Applied previous update correctly. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Tree/SiblingsDocumentTreeController.cs | 7 +- .../Media/Tree/SiblingsMediaTreeController.cs | 7 +- .../Tree/EntityTreeControllerBase.cs | 17 ++- .../Tree/UserStartNodeTreeControllerBase.cs | 20 ++- .../Entities/IUserStartNodeEntitiesService.cs | 24 +++- .../Entities/UserStartNodeEntitiesService.cs | 91 +++++++++--- .../Repositories/IEntityRepository.cs | 3 +- .../Services/DateTypeServiceExtensions.cs | 7 +- src/Umbraco.Core/Services/EntityService.cs | 2 + src/Umbraco.Core/Services/IEntityService.cs | 2 + .../Implement/EntityRepository.cs | 30 +++- ...iceMediaTests.SiblingUserAccessEntities.cs | 131 ++++++++++++++++++ ...iesServiceTests.ChildUserAccessEntities.cs | 2 +- ...sServiceTests.SiblingUserAccessEntities.cs | 131 ++++++++++++++++++ .../UserStartNodeEntitiesServiceTests.cs | 4 +- .../Services/EntityServiceTests.cs | 59 ++++++-- .../Umbraco.Tests.Integration.csproj | 6 + 17 files changed, 492 insertions(+), 51 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs index 3fec79bf36..70445d525f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs @@ -35,6 +35,9 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase [HttpGet("siblings")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) - => GetSiblings(target, before, after); + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + { + IgnoreUserStartNodesForDataType(dataTypeId); + return GetSiblings(target, before, after); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs index f5708fa638..4cc855fa6d 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs @@ -24,6 +24,9 @@ public class SiblingsMediaTreeController : MediaTreeControllerBase [HttpGet("siblings")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) - => GetSiblings(target, before, after); + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + { + IgnoreUserStartNodesForDataType(dataTypeId); + return GetSiblings(target, before, after); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs index 13bbe9bc2b..9b35fb2923 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Tree; @@ -46,7 +46,7 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB protected Task>> GetSiblings(Guid target, int before, int after) { - IEntitySlim[] siblings = EntityService.GetSiblings(target, ItemObjectType, before, after, ItemOrdering).ToArray(); + IEntitySlim[] siblings = GetSiblingEntities(target, before, after); if (siblings.Length == 0) { return Task.FromResult>>(NotFound()); @@ -110,7 +110,8 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB .ToArray(); protected virtual IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems) => - EntityService.GetPagedChildren( + EntityService + .GetPagedChildren( parentKey, ItemObjectType, skip, @@ -119,6 +120,16 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB ordering: ItemOrdering) .ToArray(); + protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after) => + EntityService + .GetSiblings( + target, + ItemObjectType, + before, + after, + ordering: ItemOrdering) + .ToArray(); + protected virtual TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities) => entities.Select(entity => MapTreeItemViewModel(parentKey, entity)).ToArray(); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs index 8c15708f1f..505330bdbd 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.Models.Entities; +using Umbraco.Cms.Api.Management.Models.Entities; using Umbraco.Cms.Api.Management.Services.Entities; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core; @@ -59,6 +59,24 @@ public abstract class UserStartNodeTreeControllerBase : EntityTreeControl return CalculateAccessMap(() => userAccessEntities, out _); } + protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after) + { + if (UserHasRootAccess() || IgnoreUserStartNodes()) + { + return base.GetSiblingEntities(target, before, after); + } + + IEnumerable userAccessEntities = _userStartNodeEntitiesService.SiblingUserAccessEntities( + ItemObjectType, + UserStartNodePaths, + target, + before, + after, + ItemOrdering); + + return CalculateAccessMap(() => userAccessEntities, out _); + } + protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities) { if (UserHasRootAccess() || IgnoreUserStartNodes()) diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs index 2753bd29b8..5fea446bec 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Api.Management.Models.Entities; @@ -64,6 +64,28 @@ public interface IUserStartNodeEntitiesService /// IEnumerable ChildUserAccessEntities(IEnumerable candidateChildren, string[] userStartNodePaths); + /// + /// Calculates the applicable sibling entities for a given object type for users without root access. + /// + /// The object type. + /// The calculated start node paths for the user. + /// The key of the target. + /// The number of applicable siblings to retrieve before the target. + /// The number of applicable siblings to retrieve after the target. + /// The ordering to apply when fetching and paginating the children. + /// A list of sibling entities applicable for the user. + /// + /// The returned entities may include entities that outside of the user start node scope, but are needed to + /// for browsing to the actual user start nodes. These entities will be marked as "no access" entities. + /// + IEnumerable SiblingUserAccessEntities( + UmbracoObjectTypes umbracoObjectType, + string[] userStartNodePaths, + Guid targetKey, + int before, + int after, + Ordering ordering) => []; + /// /// Calculates the access level of a collection of entities for users without root access. /// diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs index c811d2c9c7..6d53d2ef38 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; @@ -36,17 +36,17 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService /// public IEnumerable RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds) { - // root entities for users without root access should include: + // Root entities for users without root access should include: // - the start nodes that are actual root entities (level == 1) // - the root level ancestors to the rest of the start nodes (required for browsing to the actual start nodes - will be marked as "no access") IEntitySlim[] userStartEntities = userStartNodeIds.Any() ? _entityService.GetAll(umbracoObjectType, userStartNodeIds).ToArray() : Array.Empty(); - // find the start nodes that are at root level (level == 1) + // Find the start nodes that are at root level (level == 1). IEntitySlim[] allowedTopmostEntities = userStartEntities.Where(entity => entity.Level == 1).ToArray(); - // find the root level ancestors of the rest of the start nodes, and add those as well + // Find the root level ancestors of the rest of the start nodes, and add those as well. var nonAllowedTopmostEntityIds = userStartEntities.Except(allowedTopmostEntities) .Select(entity => int.TryParse(entity.Path.Split(Constants.CharArrays.Comma).Skip(1).FirstOrDefault(), out var id) ? id : 0) .Where(id => id > 0) @@ -63,6 +63,7 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService .ToArray(); } + /// public IEnumerable ChildUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid parentKey, int skip, int take, Ordering ordering, out long totalItems) { Attempt parentIdAttempt = _idKeyMap.GetIdForKey(parentKey, umbracoObjectType); @@ -83,40 +84,46 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService IEntitySlim[] children; if (userStartNodePaths.Any(path => $"{parent.Path},".StartsWith($"{path},"))) { - // the requested parent is one of the user start nodes (or a descendant of one), all children are by definition allowed + // The requested parent is one of the user start nodes (or a descendant of one), all children are by definition allowed. children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, ordering: ordering).ToArray(); return ChildUserAccessEntities(children, userStartNodePaths); } - // if one or more of the user start nodes are descendants of the requested parent, find the "next child IDs" in those user start node paths - // - e.g. given the user start node path "-1,2,3,4,5", if the requested parent ID is 3, the "next child ID" is 4. - var userStartNodePathIds = userStartNodePaths.Select(path => path.Split(Constants.CharArrays.Comma).Select(int.Parse).ToArray()).ToArray(); - var allowedChildIds = userStartNodePathIds - .Where(ids => ids.Contains(parentId)) - // given the previous checks, the parent ID can never be the last in the user start node path, so this is safe - .Select(ids => ids[ids.IndexOf(parentId) + 1]) - .Distinct() - .ToArray(); + int[] allowedChildIds = GetAllowedIds(userStartNodePaths, parentId); totalItems = allowedChildIds.Length; if (allowedChildIds.Length == 0) { - // the requested parent is outside the scope of any user start nodes + // The requested parent is outside the scope of any user start nodes. return []; } - // even though we know the IDs of the allowed child entities to fetch, we still use a Query to yield correctly sorted children + // Even though we know the IDs of the allowed child entities to fetch, we still use a Query to yield correctly sorted children. IQuery query = _scopeProvider.CreateQuery().Where(x => allowedChildIds.Contains(x.Id)); children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, query, ordering).ToArray(); return ChildUserAccessEntities(children, userStartNodePaths); } + private static int[] GetAllowedIds(string[] userStartNodePaths, int parentId) + { + // If one or more of the user start nodes are descendants of the requested parent, find the "next child IDs" in those user start node paths + // that are the final entries in the path. + // E.g. given the user start node path "-1,2,3,4,5", if the requested parent ID is 3, the "next child ID" is 4. + var userStartNodePathIds = userStartNodePaths.Select(path => path.Split(Constants.CharArrays.Comma).Select(int.Parse).ToArray()).ToArray(); + return userStartNodePathIds + .Where(ids => ids.Contains(parentId)) + .Select(ids => ids[ids.IndexOf(parentId) + 1]) // Given the previous checks, the parent ID can never be the last in the user start node path, so this is safe + .Distinct() + .ToArray(); + } + /// public IEnumerable ChildUserAccessEntities(IEnumerable candidateChildren, string[] userStartNodePaths) - // child entities for users without root access should include: + + // Child or sibling entities for users without root access should include: // - children that are descendant-or-self of a user start node // - children that are ancestors of a user start node (required for browsing to the actual start nodes - will be marked as "no access") - // all other candidate children should be discarded + // All other candidate children should be discarded. => candidateChildren.Select(child => { // is descendant-or-self of a start node? @@ -134,9 +141,55 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService return null; }).WhereNotNull().ToArray(); + /// + public IEnumerable SiblingUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid targetKey, int before, int after, Ordering ordering) + { + Attempt targetIdAttempt = _idKeyMap.GetIdForKey(targetKey, umbracoObjectType); + if (targetIdAttempt.Success is false) + { + return []; + } + + var targetId = targetIdAttempt.Result; + IEntitySlim? target = _entityService.Get(targetId); + if (target is null) + { + return []; + } + + IEntitySlim[] siblings; + + IEntitySlim? targetParent = _entityService.Get(target.ParentId); + if (targetParent is null) // Even if the parent is the root, we still expect to get a value here. + { + return []; + } + + if (userStartNodePaths.Any(path => $"{targetParent?.Path},".StartsWith($"{path},"))) + { + // The requested parent of the target is one of the user start nodes (or a descendant of one), all siblings are by definition allowed. + siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, ordering: ordering).ToArray(); + return ChildUserAccessEntities(siblings, userStartNodePaths); + } + + int[] allowedSiblingIds = GetAllowedIds(userStartNodePaths, targetParent.Id); + + if (allowedSiblingIds.Length == 0) + { + // The requested target is outside the scope of any user start nodes. + return []; + } + + // Even though we know the IDs of the allowed sibling entities to fetch, we still use a Query to yield correctly sorted children. + IQuery query = _scopeProvider.CreateQuery().Where(x => allowedSiblingIds.Contains(x.Id)); + siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, query, ordering).ToArray(); + return ChildUserAccessEntities(siblings, userStartNodePaths); + } + /// public IEnumerable UserAccessEntities(IEnumerable entities, string[] userStartNodePaths) - // entities for users without root access should include: + + // Entities for users without root access should include: // - entities that are descendant-or-self of a user start node as regular entities // - all other entities as "no access" entities => entities.Select(entity => new UserAccessEntity(entity, IsDescendantOrSelf(entity, userStartNodePaths))).ToArray(); diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index cdf05ca5aa..31b9c53983 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -26,9 +26,10 @@ public interface IEntityRepository : IRepository /// The key of the target entity whose siblings are to be retrieved. /// The number of siblings to retrieve before the target entity. /// The number of siblings to retrieve after the target entity. + /// An optional filter to apply to the result set. /// The ordering to apply to the siblings. /// Enumerable of sibling entities. - IEnumerable GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering) => []; + IEnumerable GetSiblings(Guid objectType, Guid targetKey, int before, int after, IQuery? filter, Ordering ordering) => []; /// /// Gets entities for a query diff --git a/src/Umbraco.Core/Services/DateTypeServiceExtensions.cs b/src/Umbraco.Core/Services/DateTypeServiceExtensions.cs index ac7e8550b2..b0cd6af6dc 100644 --- a/src/Umbraco.Core/Services/DateTypeServiceExtensions.cs +++ b/src/Umbraco.Core/Services/DateTypeServiceExtensions.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; @@ -8,11 +8,6 @@ public static class DateTypeServiceExtensions { public static bool IsDataTypeIgnoringUserStartNodes(this IDataTypeService dataTypeService, Guid key) { - if (DataTypeExtensions.IsBuildInDataType(key)) - { - return false; // built in ones can never be ignoring start nodes - } - IDataType? dataType = dataTypeService.GetAsync(key).GetAwaiter().GetResult(); if (dataType != null && dataType.ConfigurationObject is IIgnoreUserStartNodesConfig ignoreStartNodesConfig) diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index 0a284bdebf..f9408398e1 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -324,6 +324,7 @@ public class EntityService : RepositoryService, IEntityService UmbracoObjectTypes objectType, int before, int after, + IQuery? filter = null, Ordering? ordering = null) { if (before < 0) @@ -345,6 +346,7 @@ public class EntityService : RepositoryService, IEntityService key, before, after, + filter, ordering); scope.Complete(); diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index 6652062ac0..1c979f4e4f 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -177,6 +177,7 @@ public interface IEntityService /// The object type key of the entities. /// The number of siblings to retrieve before the target entity. Needs to be greater or equal to 0. /// The number of siblings to retrieve after the target entity. Needs to be greater or equal to 0. + /// An optional filter to apply to the result set. /// The ordering to apply to the siblings. /// Enumerable of sibling entities. IEnumerable GetSiblings( @@ -184,6 +185,7 @@ public interface IEntityService UmbracoObjectTypes objectType, int before, int after, + IQuery? filter = null, Ordering? ordering = null) => []; /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index c39545f6be..6e7349bf81 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -146,7 +146,13 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend } /// - public IEnumerable GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering) + public IEnumerable GetSiblings( + Guid objectType, + Guid targetKey, + int before, + int after, + IQuery? filter, + Ordering ordering) { // Ideally we don't want to have to do a second query for the parent ID, but the siblings query is already messy enough // without us also having to do a nested query for the parent ID too. @@ -167,6 +173,23 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend .From() .Where(x => x.ParentId == parentId && x.Trashed == false); + // Apply the filter if provided. Note that in doing this, we'll add more parameters to the query, so need to track + // how many so we can offset the parameter indexes for the "before" and "after" values added later. + int beforeAfterParameterIndexOffset = 0; + if (filter != null) + { + foreach (Tuple filterClause in filter.GetWhereClauses()) + { + rowNumberSql.Where(filterClause.Item1, filterClause.Item2); + + // We need to offset by one for each non-array parameter in the filter clause. + // If a query is created using Contains or some other set based operation, we'll get both the array and the + // items in the array provided in the where clauses. It's only the latter that count for applying parameters + // to the SQL statement, and hence we should only offset by them. + beforeAfterParameterIndexOffset += filterClause.Item2.Count(x => !x.GetType().IsArray); + } + } + // Find the specific row number of the target node. // We need this to determine the bounds of the row numbers to select. Sql targetRowSql = Sql() @@ -180,11 +203,12 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend IEnumerable afterArguments = targetRowSql.Arguments.Concat([after]); // Select the UniqueId of nodes which row number is within the specified range of the target node's row number. + const int BeforeAfterParameterIndex = 3; Sql? mainSql = Sql() .Select("UniqueId") .From().AppendSubQuery(rowNumberSql, "NumberedNodes") - .Where($"rn >= ({targetRowSql.SQL}) - @3", beforeArguments.ToArray()) - .Where($"rn <= ({targetRowSql.SQL}) + @3", afterArguments.ToArray()) + .Where($"rn >= ({targetRowSql.SQL}) - @{BeforeAfterParameterIndex + beforeAfterParameterIndexOffset}", beforeArguments.ToArray()) + .Where($"rn <= ({targetRowSql.SQL}) + @{BeforeAfterParameterIndex + beforeAfterParameterIndexOffset}", afterArguments.ToArray()) .OrderBy("rn"); List? keys = Database.Fetch(mainSql); diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs new file mode 100644 index 0000000000..e481b1c0ac --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs @@ -0,0 +1,131 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceMediaTests +{ + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParent_YieldsAll_AsAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1-5"].Key, + 2, + 2, + BySortOrder) + .ToArray(); + + Assert.AreEqual(5, siblings.Length); + Assert.Multiple(() => + { + for (int i = 0; i < 4; i++) + { + Assert.AreEqual(_mediaByName[$"1-{i + 3}"].Key, siblings[i].Entity.Key); + Assert.IsTrue(siblings[i].HasAccess); + } + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget_YieldsOnlyTarget_AsAllowed() + { + // See notes on ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild. + + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1"].Id, _mediaByName["1-5"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.Media, + contentStartNodePaths, + _mediaByName["1-5"].Key, + 2, + 2, + BySortOrder) + .ToArray(); + + Assert.AreEqual(1, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.IsTrue(siblings[0].HasAccess); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarget_AsAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.Media, + contentStartNodePaths, + _mediaByName["1-5"].Key, + 2, + 2, + BySortOrder) + .ToArray(); + + Assert.AreEqual(1, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.IsTrue(siblings[0].HasAccess); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-3"].Id, _mediaByName["1-5"].Id, _mediaByName["1-7"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1-5"].Key, + 2, + 2, + BySortOrder) + .ToArray(); + + Assert.AreEqual(3, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName[$"1-3"].Key, siblings[0].Entity.Key); + Assert.IsTrue(siblings[0].HasAccess); + Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[1].Entity.Key); + Assert.IsTrue(siblings[1].HasAccess); + Assert.AreEqual(_mediaByName[$"1-7"].Key, siblings[2].Entity.Key); + Assert.IsTrue(siblings[2].HasAccess); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetGrandchild_YieldsTarget_AsNotAllowed() + { + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5-1"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.Media, + mediaStartNodePaths, + _mediaByName["1-5"].Key, + 2, + 2, + BySortOrder) + .ToArray(); + + Assert.AreEqual(1, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.IsFalse(siblings[0].HasAccess); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs index 2272b60bbd..c64b61810f 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs @@ -1,4 +1,4 @@ -using NUnit.Framework; +using NUnit.Framework; using Umbraco.Cms.Core.Models; namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs new file mode 100644 index 0000000000..839a0edc02 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs @@ -0,0 +1,131 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services; + +public partial class UserStartNodeEntitiesServiceTests +{ + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParent_YieldsAll_AsAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1-5"].Key, + 2, + 2, + BySortOrder) + .ToArray(); + + Assert.AreEqual(5, siblings.Length); + Assert.Multiple(() => + { + for (int i = 0; i < 4; i++) + { + Assert.AreEqual(_contentByName[$"1-{i + 3}"].Key, siblings[i].Entity.Key); + Assert.IsTrue(siblings[i].HasAccess); + } + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget_YieldsOnlyTarget_AsAllowed() + { + // See notes on ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild. + + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1"].Id, _contentByName["1-5"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1-5"].Key, + 2, + 2, + BySortOrder) + .ToArray(); + + Assert.AreEqual(1, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.IsTrue(siblings[0].HasAccess); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarget_AsAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1-5"].Key, + 2, + 2, + BySortOrder) + .ToArray(); + + Assert.AreEqual(1, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.IsTrue(siblings[0].HasAccess); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-3"].Id, _contentByName["1-5"].Id, _contentByName["1-7"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1-5"].Key, + 2, + 2, + BySortOrder) + .ToArray(); + + Assert.AreEqual(3, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName[$"1-3"].Key, siblings[0].Entity.Key); + Assert.IsTrue(siblings[0].HasAccess); + Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[1].Entity.Key); + Assert.IsTrue(siblings[1].HasAccess); + Assert.AreEqual(_contentByName[$"1-7"].Key, siblings[2].Entity.Key); + Assert.IsTrue(siblings[2].HasAccess); + }); + } + + [Test] + public async Task SiblingUserAccessEntities_WithStartNodesOfTargetChild_YieldsTarget_AsNotAllowed() + { + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5-1"].Id); + + var siblings = UserStartNodeEntitiesService + .SiblingUserAccessEntities( + UmbracoObjectTypes.Document, + contentStartNodePaths, + _contentByName["1-5"].Key, + 2, + 2, + BySortOrder) + .ToArray(); + + Assert.AreEqual(1, siblings.Length); + Assert.Multiple(() => + { + Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[0].Entity.Key); + Assert.IsFalse(siblings[0].HasAccess); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs index 5cc6bff35d..761c56ce13 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Api.Management.Services.Entities; using Umbraco.Cms.Core; @@ -32,7 +32,7 @@ public partial class UserStartNodeEntitiesServiceTests : UmbracoIntegrationTest private IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService(); - protected readonly Ordering BySortOrder = Ordering.By("sortOrder"); + protected static readonly Ordering BySortOrder = Ordering.By("sortOrder"); protected override void ConfigureTestServices(IServiceCollection services) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs index d1031e3b2c..0db7203ad9 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs @@ -5,6 +5,7 @@ using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.ContentTypeEditing; using Umbraco.Cms.Infrastructure.Persistence; @@ -936,9 +937,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest { var children = CreateSiblingsTestData(); - var taget = children[1]; + var target = children[1]; - var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 1).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1).ToArray(); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[0].Key == children[0].Key); Assert.IsTrue(result[1].Key == children[1].Key); @@ -953,8 +954,8 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var trash = children[1]; ContentService.MoveToRecycleBin(trash); - var taget = children[2]; - var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 1).ToArray(); + var target = children[2]; + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1).ToArray(); Assert.AreEqual(3, result.Length); Assert.IsFalse(result.Any(x => x.Key == trash.Key)); Assert.IsTrue(result[0].Key == children[0].Key); @@ -962,6 +963,44 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest Assert.IsTrue(result[2].Key == children[3].Key); } + [Test] + public void EntityService_Siblings_SkipsFilteredEntities_UsingFilterWithSet() + { + var children = CreateSiblingsTestData(); + + // Apply a filter that excludes the child at index 1. We'd expect to not get this, but + // get still get one previous sibling, i.e. the entity at index 0. + Guid[] keysToExclude = [children[1].Key]; + IQuery filter = ScopeProvider.CreateQuery().Where(x => !keysToExclude.Contains(x.Key)); + + var target = children[2]; + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, filter).ToArray(); + Assert.AreEqual(3, result.Length); + Assert.IsFalse(result.Any(x => x.Key == keysToExclude[0])); + Assert.IsTrue(result[0].Key == children[0].Key); + Assert.IsTrue(result[1].Key == children[2].Key); + Assert.IsTrue(result[2].Key == children[3].Key); + } + + [Test] + public void EntityService_Siblings_SkipsFilteredEntities_UsingFilterWithoutSet() + { + var children = CreateSiblingsTestData(); + + // Apply a filter that excludes the child at index 1. We'd expect to not get this, but + // get still get one previous sibling, i.e. the entity at index 0. + var keyToExclude = children[1].Key; + IQuery filter = ScopeProvider.CreateQuery().Where(x => x.Key != keyToExclude); + + var target = children[2]; + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, filter).ToArray(); + Assert.AreEqual(3, result.Length); + Assert.IsFalse(result.Any(x => x.Key == keyToExclude)); + Assert.IsTrue(result[0].Key == children[0].Key); + Assert.IsTrue(result[1].Key == children[2].Key); + Assert.IsTrue(result[2].Key == children[3].Key); + } + [Test] public void EntityService_Siblings_RespectsOrdering() { @@ -970,8 +1009,8 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest // Order the children by name to ensure the ordering works when differing from the default sort order, the name is a GUID. children = children.OrderBy(x => x.Name).ToList(); - var taget = children[1]; - var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 1, Ordering.By(nameof(NodeDto.Text))).ToArray(); + var target = children[1]; + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, ordering: Ordering.By(nameof(NodeDto.Text))).ToArray(); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[0].Key == children[0].Key); Assert.IsTrue(result[1].Key == children[1].Key); @@ -983,8 +1022,8 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest { var children = CreateSiblingsTestData(); - var taget = children[1]; - var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 100, 1).ToArray(); + var target = children[1]; + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 100, 1).ToArray(); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[0].Key == children[0].Key); Assert.IsTrue(result[1].Key == children[1].Key); @@ -996,8 +1035,8 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest { var children = CreateSiblingsTestData(); - var taget = children[^2]; - var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 100).ToArray(); + var target = children[^2]; + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 100).ToArray(); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[^1].Key == children[^1].Key); Assert.IsTrue(result[^2].Key == children[^2].Key); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 4bb3bcb5c6..935828e92d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -286,12 +286,18 @@ UserStartNodeEntitiesServiceTests.cs + + UserStartNodeEntitiesServiceTests.cs + UserStartNodeEntitiesServiceMediaTests.cs UserStartNodeEntitiesServiceMediaTests.cs + + UserStartNodeEntitiesServiceMediaTests.cs + ContentBlueprintEditingServiceTests.cs From fcba10aecff1dbd849b8ef657fff31410cc022ab Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 5 Aug 2025 11:14:59 +0200 Subject: [PATCH 07/39] Retrieves item counts before and after the target for sibling endpoints and returns in API response (#19844) * Added user start node restrictions to sibling endpoints. * Further integration tests. * Tidy up. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Revert previous update. * Retrieves item counts before and after the target for sibling endpoints and returns in API response. * Applied previous update correctly. * Removed blank line. * Fix build and test asserts following merge. * Update OpenApi.json. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: mole --- .../ViewModels/Pagination/SubsetViewModel.cs | 17 + .../Tree/SiblingsDataTypeTreeController.cs | 5 +- .../Tree/SiblingsDocumentTreeController.cs | 5 +- ...SiblingsDocumentBlueprintTreeController.cs | 5 +- .../SiblingsDocumentTypeTreeController.cs | 5 +- .../Media/Tree/SiblingsMediaTreeController.cs | 5 +- .../Tree/SiblingsMediaTypeTreeController.cs | 5 +- .../Tree/SiblingsTemplateTreeController.cs | 5 +- .../Tree/EntityTreeControllerBase.cs | 20 +- .../Tree/UserStartNodeTreeControllerBase.cs | 8 +- src/Umbraco.Cms.Api.Management/OpenApi.json | 352 +++++++++++++++--- .../Entities/IUserStartNodeEntitiesService.cs | 11 +- .../Entities/UserStartNodeEntitiesService.cs | 32 +- .../Repositories/IEntityRepository.cs | 17 +- src/Umbraco.Core/Services/EntityService.cs | 6 +- src/Umbraco.Core/Services/IEntityService.cs | 11 +- .../Implement/EntityRepository.cs | 28 +- ...iceMediaTests.SiblingUserAccessEntities.cs | 36 +- ...sServiceTests.SiblingUserAccessEntities.cs | 37 +- .../Services/EntityServiceTests.cs | 29 +- .../Services/EntityServiceTests.cs | 2 +- 21 files changed, 526 insertions(+), 115 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Common/ViewModels/Pagination/SubsetViewModel.cs diff --git a/src/Umbraco.Cms.Api.Common/ViewModels/Pagination/SubsetViewModel.cs b/src/Umbraco.Cms.Api.Common/ViewModels/Pagination/SubsetViewModel.cs new file mode 100644 index 0000000000..34a05dfdfb --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/ViewModels/Pagination/SubsetViewModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Api.Common.ViewModels.Pagination; + +public class SubsetViewModel +{ + [Required] + public long TotalBefore { get; set; } + + [Required] + public long TotalAfter { get; set; } + + [Required] + public IEnumerable Items { get; set; } = Enumerable.Empty(); + + public static SubsetViewModel Empty() => new(); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs index 253d32e3e8..310bbb4563 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,7 +14,7 @@ public class SiblingsDataTypeTreeController : DataTypeTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) => GetSiblings(target, before, after); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs index 70445d525f..cf2f1b4f38 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs @@ -1,6 +1,7 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Services.Entities; using Umbraco.Cms.Api.Management.ViewModels.Tree; @@ -34,8 +35,8 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase [HttpGet("siblings")] [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) { IgnoreUserStartNodesForDataType(dataTypeId); return GetSiblings(target, before, after); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs index ac5578155f..528d494f77 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -14,8 +15,8 @@ public class SiblingsDocumentBlueprintTreeController : DocumentBlueprintTreeCont } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings( + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings( CancellationToken cancellationToken, Guid target, int before, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs index 7bb9c26358..9de9f5e1bc 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,8 +14,8 @@ public class SiblingsDocumentTypeTreeController : DocumentTypeTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings( + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings( CancellationToken cancellationToken, Guid target, int before, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs index 4cc855fa6d..8195d03cac 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Services.Entities; using Umbraco.Cms.Api.Management.ViewModels.Tree; @@ -23,8 +24,8 @@ public class SiblingsMediaTreeController : MediaTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) { IgnoreUserStartNodesForDataType(dataTypeId); return GetSiblings(target, before, after); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs index 1482788b57..4b445ea889 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,7 +14,7 @@ public class SiblingsMediaTypeTreeController : MediaTypeTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) => GetSiblings(target, before, after); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs index ed27092ecb..60390caa22 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,8 +14,8 @@ public class SiblingsTemplateTreeController : TemplateTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings( + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public Task>> Siblings( CancellationToken cancellationToken, Guid target, int before, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs index 9b35fb2923..80c39c5fa0 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs @@ -44,12 +44,12 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB return Task.FromResult>>(Ok(result)); } - protected Task>> GetSiblings(Guid target, int before, int after) + protected Task>> GetSiblings(Guid target, int before, int after) { - IEntitySlim[] siblings = GetSiblingEntities(target, before, after); + IEntitySlim[] siblings = GetSiblingEntities(target, before, after, out var totalBefore, out var totalAfter); if (siblings.Length == 0) { - return Task.FromResult>>(NotFound()); + return Task.FromResult>>(NotFound()); } IEntitySlim? entity = siblings.FirstOrDefault(); @@ -57,8 +57,11 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB ? EntityService.GetKey(entity.ParentId, ItemObjectType).Result : Constants.System.RootKey; - TItem[] treeItemsViewModels = MapTreeItemViewModels(parentKey, siblings); - return Task.FromResult>>(Ok(treeItemsViewModels)); + TItem[] treeItemViewModels = MapTreeItemViewModels(parentKey, siblings); + + SubsetViewModel result = SubsetViewModel(treeItemViewModels, totalBefore, totalAfter); + + return Task.FromResult>>(Ok(result)); } protected virtual async Task>> GetAncestors(Guid descendantKey, bool includeSelf = true) @@ -120,13 +123,15 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB ordering: ItemOrdering) .ToArray(); - protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after) => + protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) => EntityService .GetSiblings( target, ItemObjectType, before, after, + out totalBefore, + out totalAfter, ordering: ItemOrdering) .ToArray(); @@ -152,4 +157,7 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB protected PagedViewModel PagedViewModel(IEnumerable treeItemViewModels, long totalItems) => new() { Total = totalItems, Items = treeItemViewModels }; + + protected SubsetViewModel SubsetViewModel(IEnumerable treeItemViewModels, long totalBefore, long totalAfter) + => new() { TotalBefore = totalBefore, TotalAfter = totalAfter, Items = treeItemViewModels }; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs index 505330bdbd..6f95e6210e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs @@ -59,11 +59,11 @@ public abstract class UserStartNodeTreeControllerBase : EntityTreeControl return CalculateAccessMap(() => userAccessEntities, out _); } - protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after) + protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) { if (UserHasRootAccess() || IgnoreUserStartNodes()) { - return base.GetSiblingEntities(target, before, after); + return base.GetSiblingEntities(target, before, after, out totalBefore, out totalAfter); } IEnumerable userAccessEntities = _userStartNodeEntitiesService.SiblingUserAccessEntities( @@ -72,7 +72,9 @@ public abstract class UserStartNodeTreeControllerBase : EntityTreeControl target, before, after, - ItemOrdering); + ItemOrdering, + out totalBefore, + out totalAfter); return CalculateAccessMap(() => userAccessEntities, out _); } diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 1cd05c0cd8..2f0dde606a 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -1839,14 +1839,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DataTypeTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDataTypeTreeItemResponseModel" + } + ] } } } @@ -4453,14 +4450,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentBlueprintTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDocumentBlueprintTreeItemResponseModel" + } + ] } } } @@ -6770,14 +6764,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTypeTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDocumentTypeTreeItemResponseModel" + } + ] } } } @@ -11208,6 +11199,14 @@ "type": "integer", "format": "int32" } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -11216,14 +11215,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDocumentTreeItemResponseModel" + } + ] } } } @@ -16028,14 +16024,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTypeTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetMediaTypeTreeItemResponseModel" + } + ] } } } @@ -18550,6 +18543,14 @@ "type": "integer", "format": "int32" } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -18558,14 +18559,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetMediaTreeItemResponseModel" + } + ] } } } @@ -28769,14 +28767,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/NamedEntityTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetNamedEntityTreeItemResponseModel" + } + ] } } } @@ -36735,6 +36730,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -37021,6 +37019,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -38357,6 +38358,36 @@ }, "additionalProperties": false }, + "DocumentTypePermissionPresentationModel": { + "required": [ + "$type", + "documentTypeAlias", + "verbs" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "verbs": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "documentTypeAlias": { + "type": "string" + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTypePermissionPresentationModel": "#/components/schemas/DocumentTypePermissionPresentationModel" + } + } + }, "DocumentTypePropertyTypeContainerResponseModel": { "required": [ "id", @@ -44776,6 +44807,209 @@ }, "additionalProperties": false }, + "SubsetDataTypeTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DataTypeTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetDocumentBlueprintTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentBlueprintTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetDocumentTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetDocumentTypeTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetMediaTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetMediaTypeTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTypeTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetNamedEntityTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/NamedEntityTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "TagResponseModel": { "required": [ "id", @@ -46514,6 +46748,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -46940,6 +47177,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs index 5fea446bec..58e758f5c2 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs @@ -73,6 +73,8 @@ public interface IUserStartNodeEntitiesService /// The number of applicable siblings to retrieve before the target. /// The number of applicable siblings to retrieve after the target. /// The ordering to apply when fetching and paginating the children. + /// Outputs the total number of siblings before the target entity. + /// Outputs the total number of siblings after the target entity. /// A list of sibling entities applicable for the user. /// /// The returned entities may include entities that outside of the user start node scope, but are needed to @@ -84,7 +86,14 @@ public interface IUserStartNodeEntitiesService Guid targetKey, int before, int after, - Ordering ordering) => []; + Ordering ordering, + out long totalBefore, + out long totalAfter) + { + totalBefore = 0; + totalAfter = 0; + return []; + } /// /// Calculates the access level of a collection of entities for users without root access. diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs index 6d53d2ef38..89bd1ef981 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs @@ -64,7 +64,14 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService } /// - public IEnumerable ChildUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid parentKey, int skip, int take, Ordering ordering, out long totalItems) + public IEnumerable ChildUserAccessEntities( + UmbracoObjectTypes umbracoObjectType, + string[] userStartNodePaths, + Guid parentKey, + int skip, + int take, + Ordering ordering, + out long totalItems) { Attempt parentIdAttempt = _idKeyMap.GetIdForKey(parentKey, umbracoObjectType); if (parentIdAttempt.Success is false) @@ -142,11 +149,22 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService }).WhereNotNull().ToArray(); /// - public IEnumerable SiblingUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid targetKey, int before, int after, Ordering ordering) + public IEnumerable SiblingUserAccessEntities( + UmbracoObjectTypes umbracoObjectType, + string[] userStartNodePaths, + Guid targetKey, + int before, + int after, + Ordering ordering, + out long totalBefore, + out long totalAfter + ) { Attempt targetIdAttempt = _idKeyMap.GetIdForKey(targetKey, umbracoObjectType); if (targetIdAttempt.Success is false) { + totalBefore = 0; + totalAfter = 0; return []; } @@ -154,6 +172,8 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService IEntitySlim? target = _entityService.Get(targetId); if (target is null) { + totalBefore = 0; + totalAfter = 0; return []; } @@ -162,13 +182,15 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService IEntitySlim? targetParent = _entityService.Get(target.ParentId); if (targetParent is null) // Even if the parent is the root, we still expect to get a value here. { + totalBefore = 0; + totalAfter = 0; return []; } if (userStartNodePaths.Any(path => $"{targetParent?.Path},".StartsWith($"{path},"))) { // The requested parent of the target is one of the user start nodes (or a descendant of one), all siblings are by definition allowed. - siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, ordering: ordering).ToArray(); + siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, out totalBefore, out totalAfter, ordering: ordering).ToArray(); return ChildUserAccessEntities(siblings, userStartNodePaths); } @@ -177,12 +199,14 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService if (allowedSiblingIds.Length == 0) { // The requested target is outside the scope of any user start nodes. + totalBefore = 0; + totalAfter = 0; return []; } // Even though we know the IDs of the allowed sibling entities to fetch, we still use a Query to yield correctly sorted children. IQuery query = _scopeProvider.CreateQuery().Where(x => allowedSiblingIds.Contains(x.Id)); - siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, query, ordering).ToArray(); + siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, out totalBefore, out totalAfter, query, ordering).ToArray(); return ChildUserAccessEntities(siblings, userStartNodePaths); } diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index 31b9c53983..0ad4e42260 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -28,8 +28,23 @@ public interface IEntityRepository : IRepository /// The number of siblings to retrieve after the target entity. /// An optional filter to apply to the result set. /// The ordering to apply to the siblings. + /// Outputs the total number of siblings before the target entity. + /// Outputs the total number of siblings after the target entity. /// Enumerable of sibling entities. - IEnumerable GetSiblings(Guid objectType, Guid targetKey, int before, int after, IQuery? filter, Ordering ordering) => []; + IEnumerable GetSiblings( + Guid objectType, + Guid targetKey, + int before, + int after, + IQuery? filter, + Ordering ordering, + out long totalBefore, + out long totalAfter) + { + totalBefore = 0; + totalAfter = 0; + return []; + } /// /// Gets entities for a query diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index f9408398e1..9778e58a32 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -324,6 +324,8 @@ public class EntityService : RepositoryService, IEntityService UmbracoObjectTypes objectType, int before, int after, + out long totalBefore, + out long totalAfter, IQuery? filter = null, Ordering? ordering = null) { @@ -347,7 +349,9 @@ public class EntityService : RepositoryService, IEntityService before, after, filter, - ordering); + ordering, + out totalBefore, + out totalAfter); scope.Complete(); return siblings; diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index 1c979f4e4f..768a746b00 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -179,14 +179,23 @@ public interface IEntityService /// The number of siblings to retrieve after the target entity. Needs to be greater or equal to 0. /// An optional filter to apply to the result set. /// The ordering to apply to the siblings. + /// Outputs the total number of siblings before the target entity. + /// Outputs the total number of siblings after the target entity. /// Enumerable of sibling entities. IEnumerable GetSiblings( Guid key, UmbracoObjectTypes objectType, int before, int after, + out long totalBefore, + out long totalAfter, IQuery? filter = null, - Ordering? ordering = null) => []; + Ordering? ordering = null) + { + totalBefore = 0; + totalAfter = 0; + return []; + } /// /// Gets the children of an entity. diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index 6e7349bf81..86bdd88267 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -152,7 +152,9 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend int before, int after, IQuery? filter, - Ordering ordering) + Ordering ordering, + out long totalBefore, + out long totalAfter) { // Ideally we don't want to have to do a second query for the parent ID, but the siblings query is already messy enough // without us also having to do a nested query for the parent ID too. @@ -204,15 +206,21 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend // Select the UniqueId of nodes which row number is within the specified range of the target node's row number. const int BeforeAfterParameterIndex = 3; + var beforeAfterParameterIndex = BeforeAfterParameterIndex + beforeAfterParameterIndexOffset; + var beforeArgumentsArray = beforeArguments.ToArray(); + var afterArgumentsArray = afterArguments.ToArray(); Sql? mainSql = Sql() .Select("UniqueId") .From().AppendSubQuery(rowNumberSql, "NumberedNodes") - .Where($"rn >= ({targetRowSql.SQL}) - @{BeforeAfterParameterIndex + beforeAfterParameterIndexOffset}", beforeArguments.ToArray()) - .Where($"rn <= ({targetRowSql.SQL}) + @{BeforeAfterParameterIndex + beforeAfterParameterIndexOffset}", afterArguments.ToArray()) + .Where($"rn >= ({targetRowSql.SQL}) - @{beforeAfterParameterIndex}", beforeArgumentsArray) + .Where($"rn <= ({targetRowSql.SQL}) + @{beforeAfterParameterIndex}", afterArgumentsArray) .OrderBy("rn"); List? keys = Database.Fetch(mainSql); + totalBefore = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, beforeArgumentsArray, true); + totalAfter = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, afterArgumentsArray, false); + if (keys is null || keys.Count == 0) { return []; @@ -221,6 +229,20 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend return PerformGetAll(objectType, ordering, sql => sql.WhereIn(x => x.UniqueId, keys)); } + private long GetNumberOfSiblingsOutsideSiblingRange( + Sql rowNumberSql, + Sql targetRowSql, + int parameterIndex, + object[] arguments, + bool getBefore) + { + Sql? sql = Sql() + .SelectCount() + .From().AppendSubQuery(rowNumberSql, "NumberedNodes") + .Where($"rn {(getBefore ? "<" : ">")} ({targetRowSql.SQL}) {(getBefore ? "-" : "+")} @{parameterIndex}", arguments); + return Database.ExecuteScalar(sql); + } + public IEntitySlim? Get(Guid key, Guid objectTypeId) { diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs index e481b1c0ac..d545416eb1 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs @@ -17,9 +17,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests _mediaByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(2, totalBefore); + Assert.AreEqual(3, totalAfter); Assert.AreEqual(5, siblings.Length); Assert.Multiple(() => { @@ -45,9 +49,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests _mediaByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { @@ -68,9 +76,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests _mediaByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { @@ -82,18 +94,22 @@ public partial class UserStartNodeEntitiesServiceMediaTests [Test] public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed() { - var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-3"].Id, _mediaByName["1-5"].Id, _mediaByName["1-7"].Id); + var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-3"].Id, _mediaByName["1-5"].Id, _mediaByName["1-7"].Id, _mediaByName["1-10"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Media, mediaStartNodePaths, _mediaByName["1-5"].Key, - 2, - 2, - BySortOrder) + 1, + 1, + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(1, totalAfter); Assert.AreEqual(3, siblings.Length); Assert.Multiple(() => { @@ -118,9 +134,13 @@ public partial class UserStartNodeEntitiesServiceMediaTests _mediaByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs index 839a0edc02..9e19fa9800 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.cs @@ -17,9 +17,13 @@ public partial class UserStartNodeEntitiesServiceTests _contentByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(2, totalBefore); + Assert.AreEqual(3, totalAfter); Assert.AreEqual(5, siblings.Length); Assert.Multiple(() => { @@ -45,9 +49,13 @@ public partial class UserStartNodeEntitiesServiceTests _contentByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { @@ -68,9 +76,13 @@ public partial class UserStartNodeEntitiesServiceTests _contentByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { @@ -82,19 +94,24 @@ public partial class UserStartNodeEntitiesServiceTests [Test] public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed() { - var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-3"].Id, _contentByName["1-5"].Id, _contentByName["1-7"].Id); + var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-3"].Id, _contentByName["1-5"].Id, _contentByName["1-7"].Id, _contentByName["1-10"].Id); var siblings = UserStartNodeEntitiesService .SiblingUserAccessEntities( UmbracoObjectTypes.Document, contentStartNodePaths, _contentByName["1-5"].Key, - 2, - 2, - BySortOrder) + 1, + 1, + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(1, totalAfter); Assert.AreEqual(3, siblings.Length); + Assert.Multiple(() => { Assert.AreEqual(_contentByName[$"1-3"].Key, siblings[0].Entity.Key); @@ -118,9 +135,13 @@ public partial class UserStartNodeEntitiesServiceTests _contentByName["1-5"].Key, 2, 2, - BySortOrder) + BySortOrder, + out long totalBefore, + out long totalAfter) .ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(1, siblings.Length); Assert.Multiple(() => { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs index 0db7203ad9..644e7a0e05 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs @@ -929,7 +929,6 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var result2 = EntityService.GetPathKeys(grandChild, omitSelf: true); Assert.AreEqual($"{root.Key},{child.Key}", string.Join(",", result2)); - } [Test] @@ -939,7 +938,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var target = children[1]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(7, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[0].Key == children[0].Key); Assert.IsTrue(result[1].Key == children[1].Key); @@ -955,7 +956,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest ContentService.MoveToRecycleBin(trash); var target = children[2]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(6, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsFalse(result.Any(x => x.Key == trash.Key)); Assert.IsTrue(result[0].Key == children[0].Key); @@ -974,7 +977,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest IQuery filter = ScopeProvider.CreateQuery().Where(x => !keysToExclude.Contains(x.Key)); var target = children[2]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, filter).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter, filter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(6, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsFalse(result.Any(x => x.Key == keysToExclude[0])); Assert.IsTrue(result[0].Key == children[0].Key); @@ -993,7 +998,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest IQuery filter = ScopeProvider.CreateQuery().Where(x => x.Key != keyToExclude); var target = children[2]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, filter).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter, filter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(6, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsFalse(result.Any(x => x.Key == keyToExclude)); Assert.IsTrue(result[0].Key == children[0].Key); @@ -1010,7 +1017,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest children = children.OrderBy(x => x.Name).ToList(); var target = children[1]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, ordering: Ordering.By(nameof(NodeDto.Text))).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, out long totalBefore, out long totalAfter, ordering: Ordering.By(nameof(NodeDto.Text))).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(7, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[0].Key == children[0].Key); Assert.IsTrue(result[1].Key == children[1].Key); @@ -1023,7 +1032,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var children = CreateSiblingsTestData(); var target = children[1]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 100, 1).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 100, 1, out long totalBefore, out long totalAfter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(7, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[0].Key == children[0].Key); Assert.IsTrue(result[1].Key == children[1].Key); @@ -1036,7 +1047,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest var children = CreateSiblingsTestData(); var target = children[^2]; - var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 100).ToArray(); + var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 100, out long totalBefore, out long totalAfter).ToArray(); + Assert.AreEqual(7, totalBefore); + Assert.AreEqual(0, totalAfter); Assert.AreEqual(3, result.Length); Assert.IsTrue(result[^1].Key == children[^1].Key); Assert.IsTrue(result[^2].Key == children[^2].Key); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs index e333267274..0e8371cb5d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/EntityServiceTests.cs @@ -23,7 +23,7 @@ public class EntityServiceTests if (shouldThrow) { - Assert.Throws(() => sut.GetSiblings(Guid.NewGuid(), UmbracoObjectTypes.Document, before, after)); + Assert.Throws(() => sut.GetSiblings(Guid.NewGuid(), UmbracoObjectTypes.Document, before, after, out _, out _)); } } From 50eeb76c5a0c24a549a8e579bfd6bb8366c4e6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Tue, 5 Aug 2025 11:45:34 +0200 Subject: [PATCH 08/39] clean up double exports (#19859) --- .../src/packages/segment/collection/index.ts | 2 +- .../src/packages/segment/collection/repository/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/segment/collection/index.ts b/src/Umbraco.Web.UI.Client/src/packages/segment/collection/index.ts index 4ca3dbc38d..3d76f338dd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/segment/collection/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/segment/collection/index.ts @@ -1 +1 @@ -export { UmbSegmentCollectionRepository } from './repository/index.js'; +export * from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/segment/collection/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/segment/collection/repository/index.ts index 75a6f64007..6c61f0dd2e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/segment/collection/repository/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/segment/collection/repository/index.ts @@ -1,2 +1 @@ -export { UMB_SEGMENT_COLLECTION_REPOSITORY_ALIAS } from './constants.js'; export { UmbSegmentCollectionRepository } from './segment-collection.repository.js'; From 240e155d912b299033242ee2a459cc69c91a4f08 Mon Sep 17 00:00:00 2001 From: Andreas Zerbst <73799582+andr317c@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:29:23 +0200 Subject: [PATCH 09/39] 16 QA added relation type tests (#19490) * Updated relation type tests * Created tests * Bumped version * Fixed tests * Fixed tests * Fixes based on comments * Added waits to figure out why tests fail on pipeline * Added a reload to check if test passes on pipeline * Added reloads * Removed reload page * Reverted smokeTest command --- .../RelationTypes/RelationTypes.spec.ts | 109 +---------- .../RelationType/RelationType.spec.ts | 185 ++++++++++++++++++ 2 files changed, 186 insertions(+), 108 deletions(-) create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/RelationType/RelationType.spec.ts diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/RelationTypes/RelationTypes.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/RelationTypes/RelationTypes.spec.ts index 536cf77f5d..5f282702bb 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/RelationTypes/RelationTypes.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/RelationTypes/RelationTypes.spec.ts @@ -1,108 +1 @@ -import {test} from '@umbraco/playwright-testhelpers'; -import {expect} from "@playwright/test"; - -const relationTypeName = 'Test Relation Type'; -const objectTypeName = 'Document'; -let relationTypeId = ''; -let objectTypeId = ''; - -test.beforeEach(async ({umbracoApi, umbracoUi}) => { - await umbracoApi.relationType.ensureNameNotExists(relationTypeName); - await umbracoUi.goToBackOffice(); - await umbracoUi.relationType.goToSettingsTreeItem('Relation Types'); - objectTypeId = await umbracoApi.objectTypes.getIdByName(objectTypeName); -}); - -test.afterEach(async ({umbracoApi}) => { - await umbracoApi.relationType.ensureNameNotExists(relationTypeName); -}); - -// Skip all tests as this feature is removed -test.skip('can create a relation type', async ({umbracoApi, umbracoUi}) => { - // Act - await umbracoUi.relationType.clickActionsMenuAtRoot(); - await umbracoUi.relationType.clickCreateActionMenuOption(); - await umbracoUi.relationType.enterRelationTypeName(relationTypeName); - await umbracoUi.relationType.selectParentOption(objectTypeName); - await umbracoUi.relationType.selectChildOption(objectTypeName); - await umbracoUi.relationType.clickSaveButton(); - - // Assert - //await umbracoUi.relationType.isSuccessNotificationVisible(); - await umbracoUi.relationType.isErrorNotificationVisible(false); - expect(await umbracoApi.relationType.doesNameExist(relationTypeName)).toBeTruthy(); - // TODO: when frontend is ready, verify the new relation type name is displayed in the Relation Types tree -}); - -test.skip('can update name of a relation type', async ({umbracoApi, umbracoUi}) => { - // Arrange - const wrongRelationTypeName = 'Updated Relation Type'; - await umbracoApi.relationType.ensureNameNotExists(wrongRelationTypeName); - relationTypeId = await umbracoApi.relationType.create(wrongRelationTypeName, false, false, objectTypeId, objectTypeId); - - // Act - await umbracoUi.relationType.openRelationTypeByNameAtRoot(wrongRelationTypeName); - await umbracoUi.relationType.enterRelationTypeName(relationTypeName); - await umbracoUi.relationType.clickSaveButton(); - - // Assert - //await umbracoUi.relationType.isSuccessNotificationVisible(); - await umbracoUi.relationType.isErrorNotificationVisible(false); - const relationTypeData = await umbracoApi.relationType.get(relationTypeId); - expect(relationTypeData.name).toEqual(relationTypeName); - expect(await umbracoApi.relationType.doesNameExist(wrongRelationTypeName)).toBeFalsy(); -}); - -test.skip('can update direction value of a relation type', async ({umbracoApi, umbracoUi}) => { - // Arrange - relationTypeId = await umbracoApi.relationType.create(relationTypeName, false, false, objectTypeId, objectTypeId); - - // Act - await umbracoUi.relationType.openRelationTypeByNameAtRoot(relationTypeName); - await umbracoUi.relationType.clickBidirectionalRadioButton(); - await umbracoUi.relationType.clickSaveButton(); - - // Assert - //await umbracoUi.relationType.isSuccessNotificationVisible(); - await umbracoUi.relationType.isErrorNotificationVisible(false); - const relationTypeData = await umbracoApi.relationType.get(relationTypeId); - expect(relationTypeData.isBidirectional).toEqual(true); -}); - -test.skip('can update isDependency value of a relation type', async ({umbracoApi, umbracoUi}) => { - // Arrange - const updatedObjectTypeName = 'Media'; - relationTypeId = await umbracoApi.relationType.create(relationTypeName, false, false, objectTypeId, objectTypeId); - - // Act - await umbracoUi.relationType.openRelationTypeByNameAtRoot(relationTypeName); - await umbracoUi.relationType.clickIsDependencyToggle(); - await umbracoUi.relationType.clickSaveButton(); - - // Assert - //await umbracoUi.relationType.isSuccessNotificationVisible(); - await umbracoUi.relationType.isErrorNotificationVisible(false); - const relationTypeData = await umbracoApi.relationType.get(relationTypeId); - expect(relationTypeData.isDependency).toEqual(true); -}); - -test.skip('can delete a relation type', async ({umbracoApi, umbracoUi}) => { - // Arrange - await umbracoApi.relationType.create(relationTypeName, false, false, objectTypeId, objectTypeId); - - // Act - await umbracoUi.relationType.clickRootFolderCaretButton(); - await umbracoUi.relationType.clickActionsMenuForRelationType(relationTypeName); - await umbracoUi.relationType.clickDeleteActionMenuOption(); - await umbracoUi.relationType.clickConfirmToDeleteButton(); - - // Assert - //await umbracoUi.relationType.isSuccessNotificationVisible(); - await umbracoUi.relationType.isErrorNotificationVisible(false); - expect(await umbracoApi.relationType.doesNameExist(relationTypeName)).toBeFalsy(); - // TODO: when frontend is ready, verify the deleted relation type name is NOT displayed in the Relation Types tree -}); - -test.skip('can show relations of a relation type', async ({umbracoApi, umbracoUi}) => { - // TODO: implement this later as the frontend is missing now -}); + \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/RelationType/RelationType.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/RelationType/RelationType.spec.ts new file mode 100644 index 0000000000..2bf196f29a --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/RelationType/RelationType.spec.ts @@ -0,0 +1,185 @@ +import {test} from '@umbraco/playwright-testhelpers'; + +const documentTypeName = 'TestDocumentType'; +const contentName = 'TestContent'; + +test.beforeEach(async ({umbracoUi}) => { + await umbracoUi.goToBackOffice(); + await umbracoUi.relationType.goToSettingsTreeItem('Relations'); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); +}); + +const relationTypes = [ + {name: 'Relate Document On Copy', parentType: 'Document', childType: 'Document', biDirectional: 'true', dependency: 'false'}, + {name: 'Relate Parent Document On Delete', parentType: 'Document', childType: 'Document', biDirectional: 'false', dependency: 'false'}, + {name: 'Relate Parent Media Folder On Delete', parentType: 'Media', childType: 'Media', biDirectional: 'false', dependency: 'false'}, + {name: 'Related Document', parentType: '', childType: '', biDirectional: 'false', dependency: 'true'}, + {name: 'Related Media', parentType: '', childType: '', biDirectional: 'false', dependency: 'true'}, + {name: 'Related Member', parentType: '', childType: '', biDirectional: 'false', dependency: 'true'} +]; + +for (const relationType of relationTypes) { + test(`can see relation type ${relationType.name}`, async ({umbracoUi}) => { + // Act + await umbracoUi.waitForTimeout(2000); + await umbracoUi.relationType.goToRelationTypeWithName(relationType.name); + + // Assert + await umbracoUi.relationType.doesParentTypeContainValue(relationType.parentType); + await umbracoUi.relationType.doesChildTypeContainValue(relationType.childType); + await umbracoUi.relationType.doesBidirectionalContainValue(relationType.biDirectional); + await umbracoUi.relationType.doesDependencyContainValue(relationType.dependency); + }); +} + +test('can see related document in relation type', async ({umbracoApi, umbracoUi}) => { + // Arrange + // Content Picker + const contentPickerName = 'Content Picker'; + const contentPickerData = await umbracoApi.dataType.getByName(contentPickerName); + // Document Type + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, contentPickerName, contentPickerData.id); + // Content + const contentToBePickedName = 'ContentToBePicked'; + const contentToBePickedId = await umbracoApi.document.createDefaultDocument(contentToBePickedName, documentTypeId); + await umbracoApi.document.createDocumentWithContentPicker(contentName, documentTypeId, contentToBePickedId); + await umbracoUi.waitForTimeout(2000); + + // Act + await umbracoUi.relationType.goToRelationTypeWithName('Related Document'); + + // Assert + await umbracoUi.relationType.isRelationWithParentAndChildVisible(contentName, contentToBePickedName); +}); + +test('can see related media in relation type', async ({umbracoApi, umbracoUi}) => { + // Arrange + // Media Picker + const mediaPickerName = 'Media Picker'; + const mediaPickerData = await umbracoApi.dataType.getByName(mediaPickerName); + // Media + const mediaName = 'TestMedia'; + const mediaFileId = await umbracoApi.media.createDefaultMediaFile(mediaName); + // Document Type + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, mediaPickerName, mediaPickerData.id); + // Content + await umbracoApi.document.createDocumentWithOneMediaPicker(contentName, documentTypeId, mediaFileId); + await umbracoUi.waitForTimeout(2000); + + // Act + await umbracoUi.relationType.goToRelationTypeWithName('Related Media'); + + // Assert + await umbracoUi.relationType.isRelationWithParentAndChildVisible(contentName, mediaName); + + // Clean + await umbracoApi.media.ensureNameNotExists(mediaName); +}); + +test('can see related member in relation type', async ({umbracoApi, umbracoUi}) => { + // Arrange + // MemberPicker + const memberPickerName = 'Member Picker'; + const memberPickerData = await umbracoApi.dataType.getByName(memberPickerName); + // Member + const memberTypeData = await umbracoApi.memberType.getByName('Member'); + const memberName = 'TestMember'; + const memberEmail = 'TestMemberEmail@test.com'; + const memberId = await umbracoApi.member.createDefaultMember(memberName, memberTypeData.id, memberEmail, memberEmail, memberEmail); + // DocumentType + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, memberPickerName, memberPickerData.id); + // Content + await umbracoApi.document.createDocumentWithMemberPicker(contentName, documentTypeId, memberId); + await umbracoUi.waitForTimeout(2000); + + // Act + await umbracoUi.relationType.goToRelationTypeWithName('Related Member'); + + // Assert + await umbracoUi.relationType.isRelationWithParentAndChildVisible(contentName, memberName); + + // Clean + await umbracoApi.member.ensureNameNotExists(memberName); +}); + +test('can not see relation after content with relation is deleted', async ({umbracoApi, umbracoUi}) => { + // Arrange + // Content Picker + const contentPickerName = 'Content Picker'; + const contentPickerData = await umbracoApi.dataType.getByName(contentPickerName); + // Document Type + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, contentPickerName, contentPickerData.id); + // Content + const contentToBePickedName = 'ContentToBePicked'; + const contentToBePickedId = await umbracoApi.document.createDefaultDocument(contentToBePickedName, documentTypeId); + await umbracoApi.document.createDocumentWithContentPicker(contentName, documentTypeId, contentToBePickedId); + await umbracoUi.waitForTimeout(2000); + + await umbracoUi.relationType.goToRelationTypeWithName('Related Document'); + await umbracoUi.relationType.isRelationWithParentAndChildVisible(contentName, contentToBePickedName); + + // Act + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + + // Assert + await umbracoUi.relationType.goToSettingsTreeItem('Relations'); + await umbracoUi.relationType.goToRelationTypeWithName('Related Document'); + await umbracoUi.relationType.isRelationWithParentAndChildVisible(contentName, contentToBePickedName, false); +}); + +test('can not see relation after media with relation is deleted', async ({umbracoApi, umbracoUi}) => { + // Arrange + // Media Picker + const mediaPickerName = 'Media Picker'; + const mediaPickerData = await umbracoApi.dataType.getByName(mediaPickerName); + // Media + const mediaName = 'TestMedia'; + const mediaFileId = await umbracoApi.media.createDefaultMediaFile(mediaName); + // Document Type + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, mediaPickerName, mediaPickerData.id); + // Content + await umbracoApi.document.createDocumentWithOneMediaPicker(contentName, documentTypeId, mediaFileId); + await umbracoUi.waitForTimeout(2000); + + await umbracoUi.relationType.goToRelationTypeWithName('Related Media'); + await umbracoUi.relationType.isRelationWithParentAndChildVisible(contentName, mediaName); + + // Act + await umbracoApi.media.ensureNameNotExists(mediaName); + + // Assert + await umbracoUi.relationType.goToSettingsTreeItem('Relations'); + await umbracoUi.relationType.goToRelationTypeWithName('Related Media'); + await umbracoUi.relationType.isRelationWithParentAndChildVisible(contentName, mediaName, false); +}); + +test('can not see relation after member with relation is deleted', async ({umbracoApi, umbracoUi}) => { + // Arrange + // MemberPicker + const memberPickerName = 'Member Picker'; + const memberPickerData = await umbracoApi.dataType.getByName(memberPickerName); + // Member + const memberTypeData = await umbracoApi.memberType.getByName('Member'); + const memberName = 'TestMember'; + const memberEmail = 'TestMemberEmail@test.com'; + const memberId = await umbracoApi.member.createDefaultMember(memberName, memberTypeData.id, memberEmail, memberEmail, memberEmail); + // DocumentType + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, memberPickerName, memberPickerData.id); + // Content + await umbracoApi.document.createDocumentWithMemberPicker(contentName, documentTypeId, memberId); + await umbracoUi.waitForTimeout(2000); + + await umbracoUi.relationType.goToRelationTypeWithName('Related Member'); + await umbracoUi.relationType.isRelationWithParentAndChildVisible(contentName, memberName); + + // Act + await umbracoApi.member.ensureNameNotExists(memberName); + + // Assert + await umbracoUi.relationType.goToSettingsTreeItem('Relations'); + await umbracoUi.relationType.goToRelationTypeWithName('Related Member'); + await umbracoUi.relationType.isRelationWithParentAndChildVisible(contentName, memberName, false); +}); From 0c22d512e224b6528e348d7460426113f12cea63 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 5 Aug 2025 13:33:18 +0200 Subject: [PATCH 10/39] Adds abstraction around boot time checks for database availability (#19848) * Adds abstraction around boot time checks for database availability. * Addressed issues raised in code review. --- .../UmbracoBuilder.CoreServices.cs | 3 + .../DefaultDatabaseAvailabilityCheck.cs | 52 ++++++++++++++ .../Persistence/IDatabaseAvailabilityCheck.cs | 16 +++++ .../Runtime/RuntimeState.cs | 56 ++++++++------- .../DefaultDatabaseAvailabilityCheckTests.cs | 71 +++++++++++++++++++ 5 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 src/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheck.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/IDatabaseAvailabilityCheck.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheckTests.cs diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 66b3687cc2..8068b074fb 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -90,6 +90,9 @@ public static partial class UmbracoBuilderExtensions builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); + // Database availability check. + builder.Services.AddUnique(); + // Add runtime mode validation builder.Services.AddSingleton(); builder.RuntimeModeValidators() diff --git a/src/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheck.cs b/src/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheck.cs new file mode 100644 index 0000000000..f88c08d57e --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheck.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Logging; + +namespace Umbraco.Cms.Infrastructure.Persistence; + +/// +/// Checks if a configured database is available on boot using the default method of 5 attempts with a 1 second delay between each one. +/// +internal class DefaultDatabaseAvailabilityCheck : IDatabaseAvailabilityCheck +{ + private const int NumberOfAttempts = 5; + private const int DefaultAttemptDelayMilliseconds = 1000; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// + public DefaultDatabaseAvailabilityCheck(ILogger logger) => _logger = logger; + + /// + /// Gets or sets the number of milliseconds to delay between attempts. + /// + /// + /// Exposed for testing purposes, hence settable only internally. + /// + public int AttemptDelayMilliseconds { get; internal set; } = DefaultAttemptDelayMilliseconds; + + /// + public bool IsDatabaseAvailable(IUmbracoDatabaseFactory databaseFactory) + { + bool canConnect; + for (var i = 0; ;) + { + canConnect = databaseFactory.CanConnect; + if (canConnect || ++i == NumberOfAttempts) + { + break; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Could not immediately connect to database, trying again."); + } + + // Wait for the configured time before trying again. + Thread.Sleep(AttemptDelayMilliseconds); + } + + return canConnect; + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/IDatabaseAvailabilityCheck.cs b/src/Umbraco.Infrastructure/Persistence/IDatabaseAvailabilityCheck.cs new file mode 100644 index 0000000000..9261f3ca5c --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/IDatabaseAvailabilityCheck.cs @@ -0,0 +1,16 @@ +namespace Umbraco.Cms.Infrastructure.Persistence; + +/// +/// Checks if a configured database is available on boot. +/// +public interface IDatabaseAvailabilityCheck +{ + /// + /// Checks if the database is available for Umbraco to boot. + /// + /// The . + /// + /// A value indicating whether the database is available. + /// + bool IsDatabaseAvailable(IUmbracoDatabaseFactory databaseFactory); +} diff --git a/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs b/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs index 04ba9a2584..bd1153f51a 100644 --- a/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs +++ b/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs @@ -31,6 +31,7 @@ public class RuntimeState : IRuntimeState private readonly IConflictingRouteService _conflictingRouteService = null!; private readonly IEnumerable _databaseProviderMetadata = null!; private readonly IRuntimeModeValidationService _runtimeModeValidationService = null!; + private readonly IDatabaseAvailabilityCheck _databaseAvailabilityCheck = null!; /// /// The initial @@ -46,6 +47,7 @@ public class RuntimeState : IRuntimeState /// /// Initializes a new instance of the class. /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")] public RuntimeState( IOptions globalSettings, IOptions unattendedSettings, @@ -56,6 +58,34 @@ public class RuntimeState : IRuntimeState IConflictingRouteService conflictingRouteService, IEnumerable databaseProviderMetadata, IRuntimeModeValidationService runtimeModeValidationService) + : this( + globalSettings, + unattendedSettings, + umbracoVersion, + databaseFactory, + logger, + packageMigrationState, + conflictingRouteService, + databaseProviderMetadata, + runtimeModeValidationService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Initializes a new instance of the class. + /// + public RuntimeState( + IOptions globalSettings, + IOptions unattendedSettings, + IUmbracoVersion umbracoVersion, + IUmbracoDatabaseFactory databaseFactory, + ILogger logger, + PendingPackageMigrations packageMigrationState, + IConflictingRouteService conflictingRouteService, + IEnumerable databaseProviderMetadata, + IRuntimeModeValidationService runtimeModeValidationService, + IDatabaseAvailabilityCheck databaseAvailabilityCheck) { _globalSettings = globalSettings; _unattendedSettings = unattendedSettings; @@ -66,6 +96,7 @@ public class RuntimeState : IRuntimeState _conflictingRouteService = conflictingRouteService; _databaseProviderMetadata = databaseProviderMetadata; _runtimeModeValidationService = runtimeModeValidationService; + _databaseAvailabilityCheck = databaseAvailabilityCheck; } /// @@ -242,7 +273,7 @@ public class RuntimeState : IRuntimeState { try { - if (!TryDbConnect(databaseFactory)) + if (_databaseAvailabilityCheck.IsDatabaseAvailable(databaseFactory) is false) { return UmbracoDatabaseState.CannotConnect; } @@ -305,27 +336,4 @@ public class RuntimeState : IRuntimeState } return CurrentMigrationState != FinalMigrationState; } - - private bool TryDbConnect(IUmbracoDatabaseFactory databaseFactory) - { - // anything other than install wants a database - see if we can connect - // (since this is an already existing database, assume localdb is ready) - bool canConnect; - var tries = 5; - for (var i = 0; ;) - { - canConnect = databaseFactory.CanConnect; - if (canConnect || ++i == tries) - { - break; - } - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) - { - _logger.LogDebug("Could not immediately connect to database, trying again."); - } - Thread.Sleep(1000); - } - - return canConnect; - } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheckTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheckTests.cs new file mode 100644 index 0000000000..71c500f10f --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheckTests.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Install.Models; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Persistence.SqlServer.Services; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Persistence; + +[TestFixture] +public class DefaultDatabaseAvailabilityCheckTests +{ + [Test] + public void IsDatabaseAvailable_WithDatabaseUnavailable_ReturnsFalse() + { + var mockDatabaseFactory = new Mock(); + mockDatabaseFactory + .Setup(x => x.CanConnect) + .Returns(false); + + var sut = CreateDefaultDatabaseAvailabilityCheck(); + var result = sut.IsDatabaseAvailable(mockDatabaseFactory.Object); + Assert.IsFalse(result); + } + + [Test] + public void IsDatabaseAvailable_WithDatabaseImmediatelyAvailable_ReturnsTrue() + { + var mockDatabaseFactory = new Mock(); + mockDatabaseFactory + .Setup(x => x.CanConnect) + .Returns(true); + + var sut = CreateDefaultDatabaseAvailabilityCheck(); + var result = sut.IsDatabaseAvailable(mockDatabaseFactory.Object); + Assert.IsTrue(result); + } + + [TestCase(5, true)] + [TestCase(6, false)] + public void IsDatabaseAvailable_WithDatabaseImmediatelyAvailableAfterMultipleAttempts_ReturnsExpectedResult(int attemptsUntilConnection, bool expectedResult) + { + if (attemptsUntilConnection < 1) + { + throw new ArgumentException($"{nameof(attemptsUntilConnection)} must be greater than or equal to 1.", nameof(attemptsUntilConnection)); + } + + var attemptResults = new Queue(); + for (var i = 0; i < attemptsUntilConnection - 1; i++) + { + attemptResults.Enqueue(false); + } + + attemptResults.Enqueue(true); + + var mockDatabaseFactory = new Mock(); + mockDatabaseFactory + .Setup(x => x.CanConnect) + .Returns(attemptResults.Dequeue); + + var sut = CreateDefaultDatabaseAvailabilityCheck(); + var result = sut.IsDatabaseAvailable(mockDatabaseFactory.Object); + Assert.AreEqual(expectedResult, result); + } + + private static DefaultDatabaseAvailabilityCheck CreateDefaultDatabaseAvailabilityCheck() + => new(new NullLogger()) + { + AttemptDelayMilliseconds = 1 // Set to 1 ms for faster tests. + }; +} From 71057b5f0b676ce525a1990695ed10b7d8157c22 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:42:39 +0200 Subject: [PATCH 11/39] Build: Separate eslint logic for **/*.ts files (#19852) * build: move typescript specific eslint rules to the `**/*ts.` pattern to avoid errors for .js files * allow `Example` as class prefix * allow `example-` as custom element prefix * Removed `eslint-disable-next-line` comments from the Example classes. * Code formatting/tidy-up of Example classes --------- Co-authored-by: leekelleher --- .../enforce-umb-prefix-on-element-name.cjs | 7 ++-- .../devops/eslint/rules/umb-class-prefix.cjs | 6 ++- src/Umbraco.Web.UI.Client/eslint.config.js | 37 +++++++++++-------- .../block-custom-view/block-custom-view.ts | 3 -- .../dataset-dashboard.ts | 2 +- .../examples/icons/icons-dashboard.ts | 2 - .../manifest-picker-dashboard.ts | 8 ++-- .../sorter-dashboard.ts | 3 +- .../sorter-group.ts | 2 +- .../sorter-dashboard.ts | 3 +- .../sorter-group.ts | 2 +- .../counter-status-footer-app.element.ts | 15 +++----- .../reset-counter-menu-item.action.ts | 9 +++-- 13 files changed, 51 insertions(+), 48 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-umb-prefix-on-element-name.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-umb-prefix-on-element-name.cjs index 2fc62a385a..e290422e62 100644 --- a/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-umb-prefix-on-element-name.cjs +++ b/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-umb-prefix-on-element-name.cjs @@ -1,3 +1,5 @@ +const ALLOWED_PREFIXES = ['umb-', 'ufm-', 'test-', 'example-']; + /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { @@ -23,9 +25,8 @@ module.exports = { if (isCustomElementDecorator) { const elementName = node.arguments[0].value; - // check if the element name starts with 'umb-', 'ufm-', or 'test-', to be allow tests to have custom elements: - const prefixes = ['umb-', 'ufm-', 'test-']; - const isElementNameValid = prefixes.some((prefix) => elementName.startsWith(prefix)); + // check if the element name starts with an allowed prefix: + const isElementNameValid = ALLOWED_PREFIXES.some((prefix) => elementName.startsWith(prefix)); if (!isElementNameValid) { context.report({ diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/umb-class-prefix.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/umb-class-prefix.cjs index b9dce9c039..7856aec460 100644 --- a/src/Umbraco.Web.UI.Client/devops/eslint/rules/umb-class-prefix.cjs +++ b/src/Umbraco.Web.UI.Client/devops/eslint/rules/umb-class-prefix.cjs @@ -1,3 +1,5 @@ +const ALLOWED_PREFIXES = ['Umb', 'Example']; + /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { @@ -11,10 +13,10 @@ module.exports = { }, create: function (context) { function checkClassName(node) { - if (node.id && node.id.name && !node.id.name.startsWith('Umb')) { + if (node.id && node.id.name && !ALLOWED_PREFIXES.some((prefix) => node.id.name.startsWith(prefix))) { context.report({ node: node.id, - message: 'Class declaration should be prefixed with "Umb"', + message: `Class declaration should be prefixed with one of the following prefixes: ${ALLOWED_PREFIXES.join(', ')}`, }); } } diff --git a/src/Umbraco.Web.UI.Client/eslint.config.js b/src/Umbraco.Web.UI.Client/eslint.config.js index 56b90b5f3b..2bcbbb5b51 100644 --- a/src/Umbraco.Web.UI.Client/eslint.config.js +++ b/src/Umbraco.Web.UI.Client/eslint.config.js @@ -38,15 +38,6 @@ export default [ // Global config { - languageOptions: { - parserOptions: { - project: true, - tsconfigRootDir: import.meta.dirname, - }, - globals: { - ...globals.browser, - }, - }, plugins: { import: importPlugin, 'local-rules': localRules, @@ -77,13 +68,6 @@ export default [ excludedFileNames: ['umbraco-package'], }, ], - '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': 'error', - '@typescript-eslint/consistent-type-exports': 'error', - '@typescript-eslint/consistent-type-imports': 'error', - '@typescript-eslint/no-import-type-side-effects': 'warn', - '@typescript-eslint/no-deprecated': 'warn', 'jsdoc/check-tag-names': [ 'warn', { @@ -95,6 +79,27 @@ export default [ }, // Pattern-specific overrides + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + globals: { + ...globals.browser, + }, + }, + rules: { + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': 'error', + '@typescript-eslint/consistent-type-exports': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-import-type-side-effects': 'warn', + '@typescript-eslint/no-deprecated': 'warn', + }, + }, { files: ['**/*.js'], ...tseslint.configs.disableTypeChecked, diff --git a/src/Umbraco.Web.UI.Client/examples/block-custom-view/block-custom-view.ts b/src/Umbraco.Web.UI.Client/examples/block-custom-view/block-custom-view.ts index 5da08329ab..e81e34764b 100644 --- a/src/Umbraco.Web.UI.Client/examples/block-custom-view/block-custom-view.ts +++ b/src/Umbraco.Web.UI.Client/examples/block-custom-view/block-custom-view.ts @@ -4,11 +4,8 @@ import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; import type { UmbBlockDataType } from '@umbraco-cms/backoffice/block'; import type { UmbBlockEditorCustomViewElement } from '@umbraco-cms/backoffice/block-custom-view'; -// eslint-disable-next-line local-rules/enforce-umb-prefix-on-element-name @customElement('example-block-custom-view') -// eslint-disable-next-line local-rules/umb-class-prefix export class ExampleBlockCustomView extends UmbElementMixin(LitElement) implements UmbBlockEditorCustomViewElement { - // @property({ attribute: false }) content?: UmbBlockDataType; diff --git a/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts b/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts index 20cc50236d..a03c0f9012 100644 --- a/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts +++ b/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts @@ -1,7 +1,7 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { css, html, customElement, LitElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; -import { type UmbPropertyValueData, type UmbPropertyDatasetElement } from '@umbraco-cms/backoffice/property'; +import type { UmbPropertyValueData, UmbPropertyDatasetElement } from '@umbraco-cms/backoffice/property'; @customElement('example-dataset-dashboard') export class ExampleDatasetDashboard extends UmbElementMixin(LitElement) { diff --git a/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts b/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts index 5307bde407..920215eb85 100644 --- a/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts +++ b/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts @@ -2,9 +2,7 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { css, html, customElement, LitElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; -// eslint-disable-next-line local-rules/enforce-umb-prefix-on-element-name @customElement('example-icons-dashboard') -// eslint-disable-next-line local-rules/umb-class-prefix export class ExampleIconsDashboard extends UmbElementMixin(LitElement) { override render() { return html` diff --git a/src/Umbraco.Web.UI.Client/examples/manifest-picker/manifest-picker-dashboard.ts b/src/Umbraco.Web.UI.Client/examples/manifest-picker/manifest-picker-dashboard.ts index 43be6cfdd4..e2af9cc43f 100644 --- a/src/Umbraco.Web.UI.Client/examples/manifest-picker/manifest-picker-dashboard.ts +++ b/src/Umbraco.Web.UI.Client/examples/manifest-picker/manifest-picker-dashboard.ts @@ -5,10 +5,8 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UUISelectEvent } from '@umbraco-cms/backoffice/external/uui'; -// eslint-disable-next-line local-rules/enforce-umb-prefix-on-element-name @customElement('example-manifest-picker-dashboard') -// eslint-disable-next-line local-rules/enforce-element-suffix-on-element-class-name, local-rules/umb-class-prefix -export class ExampleManifestPickerDashboard extends UmbLitElement { +export class ExampleManifestPickerDashboardElement extends UmbLitElement { #options: Array