V14: Remove old backoffice project. (#15752)

* Move magical route to management api

* Move auth around

* Remove "New" cookies, as they are no longer needed

* Move all installer related

* Remove BackOfficeServerVariables.cs and trees

* Move webhooks to management api

* Remove remainting controllers

* Remove last services

* Move preview to management api

* Remove mroe extensions

* Remove tours

* Remove old Auth handlers

* Remove server variables entirely

* Remove old backoffice controller

* Remove controllers namespace entirely

* Move rest of preview

* move last services

* Move language file extension

* Remove old backoffice entirely (Backoffice and Web.UI projects)

* Clean up unused security classes

* Fix up installer route

* Remove obsolete tests

* Fix up DI in integration test

* Add missing property mapping

* Move core mapping into core

* Add composers to integration test

* remove identity

* Fix up DI

* Outcomment failing test :)

* Fix up remaining test

* Update mapper

* Remove the actual project files

* Remove backoffice cs proj

* Remove old backoffice from yml

* Run belissima before login

* Remove caching

* Refactor file paths

* Remove belle from static assets

* Dont refer to old project in templates

* update gitignore

* Add missing files

* Remove install view as its no longer used

* Fix up failing test

* Remove outcommented code

* Update submodule to latest

* fix build

---------

Co-authored-by: Bjarke Berg <mail@bergmania.dk>
This commit is contained in:
Nikolaj Geisle
2024-02-27 12:40:30 +01:00
committed by GitHub
parent 593f1eea6c
commit 595ee242aa
2606 changed files with 655 additions and 273115 deletions

View File

@@ -152,9 +152,9 @@ public class ContentSettings
internal const string StaticMacroErrors = "Inline";
internal const string StaticDisallowedUploadFiles = "ashx,aspx,ascx,config,cshtml,vbhtml,asmx,air,axd,xamlx";
internal const bool StaticShowDeprecatedPropertyEditors = false;
internal const string StaticLoginBackgroundImage = "assets/img/login.jpg";
internal const string StaticLoginLogoImage = "assets/img/application/umbraco_logo_blue.svg";
internal const string StaticLoginLogoImageAlternative = "assets/img/application/umbraco_logo_blue.svg";
internal const string StaticLoginBackgroundImage = "login/login.jpg";
internal const string StaticLoginLogoImage = "login/logo_dark.svg";
internal const string StaticLoginLogoImageAlternative = "login/logo_light.svg";
internal const bool StaticHideBackOfficeLogo = false;
internal const bool StaticDisableDeleteWhenReferenced = false;
internal const bool StaticDisableUnpublishWhenReferenced = false;

View File

@@ -77,11 +77,6 @@ public static partial class Constants
public const string BackOfficeTokenAuthenticationType = "UmbracoBackOfficeToken";
public const string BackOfficeTwoFactorAuthenticationType = "UmbracoTwoFactorCookie";
public const string BackOfficeTwoFactorRememberMeAuthenticationType = "UmbracoTwoFactorRememberMeCookie";
// FIXME: remove this in favor of BackOfficeAuthenticationType when the old backoffice auth is no longer necessary
public const string NewBackOfficeAuthenticationType = "NewUmbracoBackOffice";
public const string NewBackOfficeExternalAuthenticationType = "NewUmbracoExternalCookie";
public const string NewBackOfficeTwoFactorAuthenticationType = "NewUmbracoTwoFactorCookie";
public const string NewBackOfficeTwoFactorRememberMeAuthenticationType = "NewUmbracoTwoFactorRememberMeCookie";
public const string EmptyPasswordPrefix = "___UIDEMPTYPWORD__";
public const string DefaultMemberTypeAlias = "Member";

View File

@@ -97,7 +97,6 @@ public static partial class UmbracoBuilderExtensions
.Append<Hulu>()
.Append<Giphy>()
.Append<LottieFiles>();
builder.SearchableTrees().Add(() => builder.TypeLoader.GetTypes<ISearchableTree>());
builder.BackOfficeAssets();
builder.SelectorHandlers().Add(() => builder.TypeLoader.GetTypes<ISelectorHandler>());
builder.FilterHandlers().Add(() => builder.TypeLoader.GetTypes<IFilterHandler>());
@@ -245,12 +244,6 @@ public static partial class UmbracoBuilderExtensions
public static EmbedProvidersCollectionBuilder EmbedProviders(this IUmbracoBuilder builder)
=> builder.WithCollectionBuilder<EmbedProvidersCollectionBuilder>();
/// <summary>
/// Gets the back office searchable tree collection builder
/// </summary>
public static SearchableTreeCollectionBuilder SearchableTrees(this IUmbracoBuilder builder)
=> builder.WithCollectionBuilder<SearchableTreeCollectionBuilder>();
/// <summary>
/// Gets the back office custom assets collection builder
/// </summary>

View File

@@ -217,7 +217,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IEventMessagesFactory, DefaultEventMessagesFactory>();
Services.AddUnique<IEventMessagesAccessor, HybridEventMessagesAccessor>();
Services.AddUnique<ITreeService, TreeService>();
Services.AddUnique<ISmsSender, NotImplementedSmsSender>();
Services.AddUnique<IEmailSender, NotImplementedEmailSender>();
@@ -369,7 +369,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IWebhookLogService, WebhookLogService>();
Services.AddUnique<IWebhookLogFactory, WebhookLogFactory>();
Services.AddUnique<IWebhookRequestService, WebhookRequestService>();
// Data type configuration cache
Services.AddUnique<IDataTypeConfigurationCache, DataTypeConfigurationCache>();
Services.AddNotificationHandler<DataTypeCacheRefresherNotification, DataTypeConfigurationCacheRefresher>();

View File

@@ -7,6 +7,7 @@ using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Models.Mapping;
@@ -20,6 +21,9 @@ public class ContentPropertyMapDefinition : IMapDefinition
private readonly ContentPropertyBasicMapper<ContentPropertyBasic> _contentPropertyBasicConverter;
private readonly ContentPropertyDisplayMapper _contentPropertyDisplayMapper;
private readonly ContentPropertyDtoMapper _contentPropertyDtoConverter;
private readonly CommonMapper _commonMapper;
private readonly ContentBasicSavedStateMapper<ContentPropertyBasic> _basicStateMapper;
public ContentPropertyMapDefinition(
ICultureDictionary cultureDictionary,
@@ -28,8 +32,10 @@ public class ContentPropertyMapDefinition : IMapDefinition
ILocalizedTextService textService,
ILoggerFactory loggerFactory,
PropertyEditorCollection propertyEditors,
IDataTypeConfigurationCache dataTypeConfigurationCache)
CommonMapper commonMapper)
{
_commonMapper = commonMapper;
_basicStateMapper = new ContentBasicSavedStateMapper<ContentPropertyBasic>();
_contentPropertyBasicConverter = new ContentPropertyBasicMapper<ContentPropertyBasic>(
dataTypeService,
entityService,
@@ -49,24 +55,6 @@ public class ContentPropertyMapDefinition : IMapDefinition
propertyEditors);
}
[Obsolete("Please use constructor that takes an IDataTypeConfigurationCache. Will be removed in V14.")]
public ContentPropertyMapDefinition(
ICultureDictionary cultureDictionary,
IDataTypeService dataTypeService,
IEntityService entityService,
ILocalizedTextService textService,
ILoggerFactory loggerFactory,
PropertyEditorCollection propertyEditors)
: this(
cultureDictionary,
dataTypeService,
entityService,
textService,
loggerFactory,
propertyEditors,
StaticServiceProvider.Instance.GetRequiredService<IDataTypeConfigurationCache>())
{ }
public void DefineMaps(IUmbracoMapper mapper)
{
mapper.Define<PropertyGroup, Tab<ContentPropertyDisplay>>(
@@ -74,6 +62,8 @@ public class ContentPropertyMapDefinition : IMapDefinition
mapper.Define<IProperty, ContentPropertyBasic>((source, context) => new ContentPropertyBasic(), Map);
mapper.Define<IProperty, ContentPropertyDto>((source, context) => new ContentPropertyDto(), Map);
mapper.Define<IProperty, ContentPropertyDisplay>((source, context) => new ContentPropertyDisplay(), Map);
mapper.Define<IContent, ContentItemBasic<ContentPropertyBasic>>((source, context) => new ContentItemBasic<ContentPropertyBasic>(), Map);
mapper.Define<IContent, ContentPropertyCollectionDto>((source, context) => new ContentPropertyCollectionDto(), Map);
}
// Umbraco.Code.MapAll -Properties -Alias -Expanded
@@ -101,4 +91,81 @@ public class ContentPropertyMapDefinition : IMapDefinition
// assume this is mapping everything and no MapAll is required
_contentPropertyDisplayMapper.Map(source, target, context);
// Umbraco.Code.MapAll -Alias
private void Map(IContent source, ContentItemBasic<ContentPropertyBasic> target, MapperContext context)
{
target.ContentTypeId = source.ContentType.Id;
target.ContentTypeAlias = source.ContentType.Alias;
target.CreateDate = source.CreateDate;
target.Edited = source.Edited;
target.Icon = source.ContentType.Icon;
target.Id = source.Id;
target.Key = source.Key;
target.Name = GetName(source, context);
target.Owner = _commonMapper.GetOwner(source, context);
target.ParentId = source.ParentId;
target.Path = source.Path;
target.Properties = context.MapEnumerable<IProperty, ContentPropertyBasic>(source.Properties).WhereNotNull();
target.SortOrder = source.SortOrder;
target.State = _basicStateMapper.Map(source, context);
target.Trashed = source.Trashed;
target.Udi =
Udi.Create(source.Blueprint ? Constants.UdiEntityType.DocumentBlueprint : Constants.UdiEntityType.Document, source.Key);
target.UpdateDate = GetUpdateDate(source, context);
target.Updater = _commonMapper.GetCreator(source, context);
target.VariesByCulture = source.ContentType.VariesByCulture();
}
// Umbraco.Code.MapAll
private static void Map(IContent source, ContentPropertyCollectionDto target, MapperContext context) =>
target.Properties = context.MapEnumerable<IProperty, ContentPropertyDto>(source.Properties).WhereNotNull();
private string? GetName(IContent source, MapperContext context)
{
// invariant = only 1 name
if (!source.ContentType.VariesByCulture())
{
return source.Name;
}
// variant = depends on culture
var culture = context.GetCulture();
// if there's no culture here, the issue is somewhere else (UI, whatever) - throw!
if (culture == null)
{
throw new InvalidOperationException("Missing culture in mapping options.");
}
// if we don't have a name for a culture, it means the culture is not available, and
// hey we should probably not be mapping it, but it's too late, return a fallback name
return source.CultureInfos is not null &&
source.CultureInfos.TryGetValue(culture, out ContentCultureInfos name) && !name.Name.IsNullOrWhiteSpace()
? name.Name
: $"({source.Name})";
}
private DateTime GetUpdateDate(IContent source, MapperContext context)
{
// invariant = global date
if (!source.ContentType.VariesByCulture())
{
return source.UpdateDate;
}
// variant = depends on culture
var culture = context.GetCulture();
// if there's no culture here, the issue is somewhere else (UI, whatever) - throw!
if (culture == null)
{
throw new InvalidOperationException("Missing culture in mapping options.");
}
// if we don't have a date for a culture, it means the culture is not available, and
// hey we should probably not be mapping it, but it's too late, return a fallback date
DateTime? date = source.GetUpdateDate(culture);
return date ?? source.UpdateDate;
}
}

View File

@@ -0,0 +1,50 @@
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Models.Mapping;
/// <summary>
/// Declares model mappings for media.
/// </summary>
public class MediaMapDefinition : IMapDefinition
{
private readonly CommonMapper _commonMapper;
public MediaMapDefinition(CommonMapper commonMapper)
{
_commonMapper = commonMapper;
}
public void DefineMaps(IUmbracoMapper mapper)
{
mapper.Define<IMedia, ContentPropertyCollectionDto>((source, context) => new ContentPropertyCollectionDto(), Map);
mapper.Define<IMedia, ContentItemBasic<ContentPropertyBasic>>((source, context) => new ContentItemBasic<ContentPropertyBasic>(), Map);
}
// Umbraco.Code.MapAll
private static void Map(IMedia source, ContentPropertyCollectionDto target, MapperContext context) =>
target.Properties = context.MapEnumerable<IProperty, ContentPropertyDto>(source.Properties).WhereNotNull();
// Umbraco.Code.MapAll -Edited -Updater -Alias
private void Map(IMedia source, ContentItemBasic<ContentPropertyBasic> target, MapperContext context)
{
target.ContentTypeId = source.ContentType.Id;
target.ContentTypeAlias = source.ContentType.Alias;
target.CreateDate = source.CreateDate;
target.Icon = source.ContentType.Icon;
target.Id = source.Id;
target.Key = source.Key;
target.Name = source.Name;
target.Owner = _commonMapper.GetOwner(source, context);
target.ParentId = source.ParentId;
target.Path = source.Path;
target.Properties = context.MapEnumerable<IProperty, ContentPropertyBasic>(source.Properties).WhereNotNull();
target.SortOrder = source.SortOrder;
target.State = null;
target.Trashed = source.Trashed;
target.Udi = Udi.Create(Constants.UdiEntityType.Media, source.Key);
target.UpdateDate = source.UpdateDate;
target.VariesByCulture = source.ContentType.VariesByCulture();
}
}

View File

@@ -1,13 +1,23 @@
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Models.Mapping;
/// <inheritdoc />
public class MemberMapDefinition : IMapDefinition
{
private readonly CommonMapper _commonMapper;
public MemberMapDefinition(CommonMapper commonMapper) => _commonMapper = commonMapper;
/// <inheritdoc />
public void DefineMaps(IUmbracoMapper mapper) => mapper.Define<MemberSave, IMember>(Map);
public void DefineMaps(IUmbracoMapper mapper)
{
mapper.Define<MemberSave, IMember>(Map);
mapper.Define<IMember, MemberBasic>((source, context) => new MemberBasic(), Map);
mapper.Define<IMember, ContentPropertyCollectionDto>((source, context) => new ContentPropertyCollectionDto(), Map);
}
private static void Map(MemberSave source, IMember target, MapperContext context)
{
@@ -28,4 +38,36 @@ public class MemberMapDefinition : IMapDefinition
// TODO: add groups as required
}
// Umbraco.Code.MapAll -Trashed -Edited -Updater -Alias -VariesByCulture
private void Map(IMember source, MemberBasic target, MapperContext context)
{
target.ContentTypeId = source.ContentType.Id;
target.ContentTypeAlias = source.ContentType.Alias;
target.CreateDate = source.CreateDate;
target.Email = source.Email;
target.Icon = source.ContentType.Icon;
target.Id = int.MaxValue;
target.Key = source.Key;
target.Name = source.Name;
target.Owner = _commonMapper.GetOwner(source, context);
target.ParentId = source.ParentId;
target.Path = source.Path;
target.Properties = context.MapEnumerable<IProperty, ContentPropertyBasic>(source.Properties).WhereNotNull();
target.SortOrder = source.SortOrder;
target.State = null;
target.Udi = Udi.Create(Constants.UdiEntityType.Member, source.Key);
target.UpdateDate = source.UpdateDate;
target.Username = source.Username;
target.FailedPasswordAttempts = source.FailedPasswordAttempts;
target.Approved = source.IsApproved;
target.LockedOut = source.IsLockedOut;
target.LastLockoutDate = source.LastLockoutDate;
target.LastLoginDate = source.LastLoginDate;
target.LastPasswordChangeDate = source.LastPasswordChangeDate;
}
// Umbraco.Code.MapAll
private static void Map(IMember source, ContentPropertyCollectionDto target, MapperContext context) =>
target.Properties = context.MapEnumerable<IProperty, ContentPropertyDto>(source.Properties).WhereNotNull();
}

View File

@@ -4,8 +4,8 @@ namespace Umbraco.Cms.Core.Security;
public class BackOfficeAuthenticationTypeSettings
{
public string AuthenticationType { get; set; } = Constants.Security.NewBackOfficeAuthenticationType;
public string ExternalAuthenticationType { get; set; } = Constants.Security.NewBackOfficeExternalAuthenticationType;
public string TwoFactorAuthenticationType { get; set; } = Constants.Security.NewBackOfficeTwoFactorAuthenticationType;
public string TwoFactorRememberMeAuthenticationType { get; set; } = Constants.Security.NewBackOfficeTwoFactorRememberMeAuthenticationType;
public string AuthenticationType { get; set; } = Constants.Security.BackOfficeAuthenticationType;
public string ExternalAuthenticationType { get; set; } = Constants.Security.BackOfficeExternalAuthenticationType;
public string TwoFactorAuthenticationType { get; set; } = Constants.Security.BackOfficeTwoFactorAuthenticationType;
public string TwoFactorRememberMeAuthenticationType { get; set; } = Constants.Security.BackOfficeTwoFactorRememberMeAuthenticationType;
}

View File

@@ -1,19 +0,0 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
public interface IIconService
{
/// <summary>
/// Gets the svg string for the icon name found at the global icons path
/// </summary>
/// <param name="iconName"></param>
/// <returns></returns>
IconModel? GetIcon(string iconName);
/// <summary>
/// Gets a list of all svg icons found at at the global icons path.
/// </summary>
/// <returns></returns>
IReadOnlyDictionary<string, string>? GetIcons();
}

View File

@@ -1,30 +0,0 @@
using Umbraco.Cms.Core.Trees;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Represents a service which manages section trees.
/// </summary>
public interface ITreeService
{
/// <summary>
/// Gets a tree.
/// </summary>
/// <param name="treeAlias">The tree alias.</param>
Tree? GetByAlias(string treeAlias);
/// <summary>
/// Gets all trees.
/// </summary>
IEnumerable<Tree> GetAll(TreeUse use = TreeUse.Main);
/// <summary>
/// Gets all trees for a section.
/// </summary>
IEnumerable<Tree> GetBySection(string sectionAlias, TreeUse use = TreeUse.Main);
/// <summary>
/// Gets all trees for a section, grouped.
/// </summary>
IDictionary<string, IEnumerable<Tree>> GetBySectionGrouped(string sectionAlias, TreeUse use = TreeUse.Main);
}

View File

@@ -1,41 +0,0 @@
using Umbraco.Cms.Core.Trees;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Implements <see cref="ITreeService" />.
/// </summary>
public class TreeService : ITreeService
{
private readonly TreeCollection _treeCollection;
/// <summary>
/// Initializes a new instance of the <see cref="TreeService" /> class.
/// </summary>
/// <param name="treeCollection"></param>
public TreeService(TreeCollection treeCollection) => _treeCollection = treeCollection;
/// <inheritdoc />
public Tree? GetByAlias(string treeAlias) => _treeCollection.FirstOrDefault(x => x.TreeAlias == treeAlias);
/// <inheritdoc />
public IEnumerable<Tree> GetAll(TreeUse use = TreeUse.Main)
// use HasFlagAny: if use is Main|Dialog, we want to return Main *and* Dialog trees
=> _treeCollection.Where(x => x.TreeUse.HasFlagAny(use));
/// <inheritdoc />
public IEnumerable<Tree> GetBySection(string sectionAlias, TreeUse use = TreeUse.Main)
// use HasFlagAny: if use is Main|Dialog, we want to return Main *and* Dialog trees
=> _treeCollection.Where(x => x.SectionAlias.InvariantEquals(sectionAlias) && x.TreeUse.HasFlagAny(use))
.OrderBy(x => x.SortOrder).ToList();
/// <inheritdoc />
public IDictionary<string, IEnumerable<Tree>>
GetBySectionGrouped(string sectionAlias, TreeUse use = TreeUse.Main) =>
GetBySection(sectionAlias, use).GroupBy(x => x.TreeGroup).ToDictionary(
x => x.Key ?? string.Empty,
x => (IEnumerable<Tree>)x.ToArray());
}

View File

@@ -1,64 +0,0 @@
namespace Umbraco.Cms.Core.Trees;
[AttributeUsage(AttributeTargets.Class)]
public sealed class SearchableTreeAttribute : Attribute
{
public const int DefaultSortOrder = 1000;
/// <summary>
/// This constructor will assume that the method name equals `format(searchResult, appAlias, treeAlias)`.
/// </summary>
/// <param name="serviceName">Name of the service.</param>
public SearchableTreeAttribute(string serviceName)
: this(serviceName, string.Empty)
{
}
/// <summary>
/// This constructor defines both the Angular service and method name to use.
/// </summary>
/// <param name="serviceName">Name of the service.</param>
/// <param name="methodName">Name of the method.</param>
public SearchableTreeAttribute(string serviceName, string methodName)
: this(serviceName, methodName, DefaultSortOrder)
{
}
/// <summary>
/// This constructor defines both the Angular service and method name to use and explicitly defines a sort order for
/// the results
/// </summary>
/// <param name="serviceName">Name of the service.</param>
/// <param name="methodName">Name of the method.</param>
/// <param name="sortOrder">The sort order.</param>
/// <exception cref="ArgumentNullException">
/// serviceName
/// or
/// methodName
/// </exception>
/// <exception cref="ArgumentException">Value can't be empty or consist only of white-space characters. - serviceName</exception>
public SearchableTreeAttribute(string serviceName, string methodName, int sortOrder)
{
if (serviceName == null)
{
throw new ArgumentNullException(nameof(serviceName));
}
if (string.IsNullOrWhiteSpace(serviceName))
{
throw new ArgumentException(
"Value can't be empty or consist only of white-space characters.",
nameof(serviceName));
}
ServiceName = serviceName;
MethodName = methodName ?? throw new ArgumentNullException(nameof(methodName));
SortOrder = sortOrder;
}
public string ServiceName { get; }
public string MethodName { get; }
public int SortOrder { get; }
}

View File

@@ -1,45 +0,0 @@
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Trees;
public class SearchableTreeCollection : BuilderCollectionBase<ISearchableTree>
{
private readonly Dictionary<string, SearchableApplicationTree> _dictionary;
public SearchableTreeCollection(Func<IEnumerable<ISearchableTree>> items, ITreeService treeService)
: base(items) =>
_dictionary = CreateDictionary(treeService);
public IReadOnlyDictionary<string, SearchableApplicationTree> SearchableApplicationTrees => _dictionary;
public SearchableApplicationTree this[string key] => _dictionary[key];
private Dictionary<string, SearchableApplicationTree> CreateDictionary(ITreeService treeService)
{
Tree[] appTrees = treeService.GetAll()
.OrderBy(x => x.SortOrder)
.ToArray();
var dictionary = new Dictionary<string, SearchableApplicationTree>(StringComparer.OrdinalIgnoreCase);
ISearchableTree[] searchableTrees = this.ToArray();
foreach (Tree appTree in appTrees)
{
ISearchableTree? found = searchableTrees.FirstOrDefault(x => x.TreeAlias.InvariantEquals(appTree.TreeAlias));
if (found != null)
{
SearchableTreeAttribute? searchableTreeAttribute =
found.GetType().GetCustomAttribute<SearchableTreeAttribute>(false);
dictionary[found.TreeAlias] = new SearchableApplicationTree(
appTree.SectionAlias,
appTree.TreeAlias,
searchableTreeAttribute?.SortOrder ?? SearchableTreeAttribute.DefaultSortOrder,
searchableTreeAttribute?.ServiceName ?? string.Empty,
searchableTreeAttribute?.MethodName ?? string.Empty,
found);
}
}
return dictionary;
}
}

View File

@@ -1,13 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.Composing;
namespace Umbraco.Cms.Core.Trees;
public class SearchableTreeCollectionBuilder : LazyCollectionBuilderBase<SearchableTreeCollectionBuilder,
SearchableTreeCollection, ISearchableTree>
{
protected override SearchableTreeCollectionBuilder This => this;
// per request because generally an instance of ISearchableTree is a controller
protected override ServiceLifetime CollectionLifetime => ServiceLifetime.Scoped;
}