Merge remote-tracking branch 'origin/release/16.0' into v16/merge-16-release-to-main

# Conflicts:
#	build/nightly-E2E-test-pipelines.yml
#	src/Umbraco.Web.UI.Client/eslint.config.js
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockGrid/VariantBlockGrid.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockList/VariantBlockList.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentInfoTab.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithCheckboxList.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDropdown.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithMediaPicker.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithRadiobox.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithTextarea.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithTextstring.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RedirectManagement.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/ContentWithTiptap.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/VariantTipTapBlocks.spec.ts
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Dictionary/Dictionary.spec.ts
#	version.json
This commit is contained in:
Andreas Zerbst
2025-05-20 12:25:14 +02:00
348 changed files with 5012 additions and 1386 deletions

View File

@@ -432,6 +432,33 @@ stages:
displayName: Start SQL Server Docker image (Linux)
condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
- powershell: |
$maxAttempts = 12
$attempt = 0
$status = ""
while (($status -ne 'running') -and ($attempt -lt $maxAttempts)) {
Start-Sleep -Seconds 5
# We use the docker inspect command to check the status of the container. If the container is not running, we wait 5 seconds and try again. And if reaches 12 attempts, we fail the build.
$status = docker inspect -f '{{.State.Status}}' mssql
if ($status -ne 'running') {
Write-Host "Waiting for SQL Server to be ready... Attempt $($attempt + 1)"
$attempt++
}
}
if ($status -eq 'running') {
Write-Host "SQL Server container is running"
docker ps -a
} else {
Write-Host "SQL Server did not become ready in time. Last known status: $status"
docker logs mssql
exit 1
}
displayName: Wait for SQL Server to be ready (Linux)
condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
- pwsh: SqlLocalDB start MSSQLLocalDB
displayName: Start SQL Server LocalDB (Windows)
condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))

View File

@@ -300,17 +300,17 @@ stages:
testCommand: "npm run testSqlite -- --shard=1/3"
vmImage: "ubuntu-latest"
SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True"
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
LinuxPart2Of3:
testCommand: "npm run testSqlite -- --shard=2/3"
vmImage: "ubuntu-latest"
SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True"
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
LinuxPart3Of3:
testCommand: "npm run testSqlite -- --shard=3/3"
vmImage: "ubuntu-latest"
SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True"
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
WindowsPart1Of3:
vmImage: "windows-latest"
testCommand: "npm run testSqlite -- --shard=1/3"

View File

@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -42,12 +42,16 @@ public class CopyDocumentController : DocumentControllerBase
Guid id,
CopyDocumentRequestModel copyDocumentRequestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
AuthorizationResult sourceAuthorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionCopy.ActionLetter, new[] { copyDocumentRequestModel.Target?.Id, id }),
ContentPermissionResource.WithKeys(ActionCopy.ActionLetter, [id]),
AuthorizationPolicies.ContentPermissionByResource);
AuthorizationResult destinationAuthorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionNew.ActionLetter, [copyDocumentRequestModel.Target?.Id]),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
if (sourceAuthorizationResult.Succeeded is false || destinationAuthorizationResult.Succeeded is false)
{
return Forbidden();
}

View File

@@ -28,12 +28,13 @@ public class SearchDocumentItemController : DocumentItemControllerBase
CancellationToken cancellationToken,
string query,
bool? trashed = null,
string? culture = null,
int skip = 0,
int take = 100,
Guid? parentId = null,
[FromQuery] IEnumerable<Guid>? allowedDocumentTypes = null)
{
PagedModel<IEntitySlim> searchResult = await _indexedEntitySearchService.SearchAsync(UmbracoObjectTypes.Document, query, parentId, allowedDocumentTypes, trashed, skip, take);
PagedModel<IEntitySlim> searchResult = await _indexedEntitySearchService.SearchAsync(UmbracoObjectTypes.Document, query, parentId, allowedDocumentTypes, trashed, culture, skip, take);
var result = new PagedModel<DocumentItemResponseModel>
{
Items = searchResult.Items.OfType<IDocumentEntitySlim>().Select(_documentPresentationFactory.CreateItemResponseModel),

View File

@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -39,6 +39,20 @@ public class MoveDocumentController : DocumentControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Move(CancellationToken cancellationToken, Guid id, MoveDocumentRequestModel moveDocumentRequestModel)
{
AuthorizationResult sourceAuthorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionMove.ActionLetter, [id]),
AuthorizationPolicies.ContentPermissionByResource);
AuthorizationResult destinationAuthorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionNew.ActionLetter, [moveDocumentRequestModel.Target?.Id]),
AuthorizationPolicies.ContentPermissionByResource);
if (sourceAuthorizationResult.Succeeded is false || destinationAuthorizationResult.Succeeded is false)
{
return Forbidden();
}
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionMove.ActionLetter, new[] { moveDocumentRequestModel.Target?.Id, id }),

View File

@@ -21,17 +21,20 @@ public class SearchMediaItemController : MediaItemControllerBase
_mediaPresentationFactory = mediaPresentationFactory;
}
[NonAction]
[Obsolete("Scheduled to be removed in v16, use the non obsoleted method instead")]
public Task<IActionResult> SearchFromParentWithAllowedTypes(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null, [FromQuery]IEnumerable<Guid>? allowedMediaTypes = null)
=> SearchFromParentWithAllowedTypes(cancellationToken, query, null, skip, take, parentId);
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<MediaItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> SearchFromParentWithAllowedTypes(CancellationToken cancellationToken, string query, bool? trashed = null, int skip = 0, int take = 100, Guid? parentId = null, [FromQuery]IEnumerable<Guid>? allowedMediaTypes = null)
public async Task<IActionResult> SearchFromParentWithAllowedTypes(
CancellationToken cancellationToken,
string query,
bool? trashed = null,
string? culture = null,
int skip = 0,
int take = 100,
Guid? parentId = null,
[FromQuery]IEnumerable<Guid>? allowedMediaTypes = null)
{
PagedModel<IEntitySlim> searchResult = await _indexedEntitySearchService.SearchAsync(UmbracoObjectTypes.Media, query, parentId, allowedMediaTypes, trashed, skip, take);
PagedModel<IEntitySlim> searchResult = await _indexedEntitySearchService.SearchAsync(UmbracoObjectTypes.Media, query, parentId, allowedMediaTypes, trashed, culture, skip, take);
var result = new PagedModel<MediaItemResponseModel>
{
Items = searchResult.Items.OfType<IMediaEntitySlim>().Select(_mediaPresentationFactory.CreateItemResponseModel),

View File

@@ -14,7 +14,6 @@ using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.Segment;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)]
public class AllSegmentController : SegmentControllerBase
{
private readonly ISegmentService _segmentService;

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Management.NotificationHandlers;
using Umbraco.Cms.Api.Management.Security;
using Umbraco.Cms.Api.Management.Services;
using Umbraco.Cms.Api.Management.Telemetry;
@@ -12,6 +13,7 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Net;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
@@ -74,6 +76,9 @@ public static partial class UmbracoBuilderExtensions
services.AddScoped<IBackOfficeExternalLoginService, BackOfficeExternalLoginService>();
// Register a notification handler to interrogate the registered external login providers at startup.
builder.AddNotificationHandler<UmbracoApplicationStartingNotification, ExternalLoginProviderStartupHandler>();
return builder;
}

View File

@@ -0,0 +1,44 @@
using Umbraco.Cms.Api.Management.Security;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Api.Management.NotificationHandlers;
/// <summary>
/// Invalidates backoffice sessions and clears external logins for removed providers if the external login
/// provider setup has changed.
/// </summary>
internal sealed class ExternalLoginProviderStartupHandler : INotificationHandler<UmbracoApplicationStartingNotification>
{
private readonly IBackOfficeExternalLoginProviders _backOfficeExternalLoginProviders;
private readonly IRuntimeState _runtimeState;
private readonly IServerRoleAccessor _serverRoleAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="ExternalLoginProviderStartupHandler"/> class.
/// </summary>
public ExternalLoginProviderStartupHandler(
IBackOfficeExternalLoginProviders backOfficeExternalLoginProviders,
IRuntimeState runtimeState,
IServerRoleAccessor serverRoleAccessor)
{
_backOfficeExternalLoginProviders = backOfficeExternalLoginProviders;
_runtimeState = runtimeState;
_serverRoleAccessor = serverRoleAccessor;
}
/// <inheritdoc/>
public void Handle(UmbracoApplicationStartingNotification notification)
{
if (_runtimeState.Level != RuntimeLevel.Run ||
_serverRoleAccessor.CurrentServerRole == ServerRole.Subscriber)
{
return;
}
_backOfficeExternalLoginProviders.InvalidateSessionsIfExternalLoginProvidersChanged();
}
}

View File

@@ -10159,6 +10159,13 @@
"type": "boolean"
}
},
{
"name": "culture",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "skip",
"in": "query",
@@ -15918,6 +15925,13 @@
"type": "boolean"
}
},
{
"name": "culture",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "skip",
"in": "query",

View File

@@ -1,5 +1,9 @@
using Microsoft.AspNetCore.Authentication;
using Umbraco.Cms.Core.Security;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Security;
@@ -8,13 +12,37 @@ public class BackOfficeExternalLoginProviders : IBackOfficeExternalLoginProvider
{
private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;
private readonly Dictionary<string, BackOfficeExternalLoginProvider> _externalLogins;
private readonly IKeyValueService _keyValueService;
private readonly IExternalLoginWithKeyService _externalLoginWithKeyService;
private readonly ILogger<BackOfficeExternalLoginProviders> _logger;
private const string ExternalLoginProvidersKey = "Umbraco.Cms.Web.BackOffice.Security.BackOfficeExternalLoginProviders";
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public BackOfficeExternalLoginProviders(
IEnumerable<BackOfficeExternalLoginProvider> externalLogins,
IAuthenticationSchemeProvider authenticationSchemeProvider)
: this(
externalLogins,
authenticationSchemeProvider,
StaticServiceProvider.Instance.GetRequiredService<IKeyValueService>(),
StaticServiceProvider.Instance.GetRequiredService<IExternalLoginWithKeyService>(),
StaticServiceProvider.Instance.GetRequiredService<ILogger<BackOfficeExternalLoginProviders>>())
{
}
public BackOfficeExternalLoginProviders(
IEnumerable<BackOfficeExternalLoginProvider> externalLogins,
IAuthenticationSchemeProvider authenticationSchemeProvider,
IKeyValueService keyValueService,
IExternalLoginWithKeyService externalLoginWithKeyService,
ILogger<BackOfficeExternalLoginProviders> logger)
{
_externalLogins = externalLogins.ToDictionary(x => x.AuthenticationType);
_authenticationSchemeProvider = authenticationSchemeProvider;
_keyValueService = keyValueService;
_externalLoginWithKeyService = externalLoginWithKeyService;
_logger = logger;
}
/// <inheritdoc />
@@ -60,4 +88,25 @@ public class BackOfficeExternalLoginProviders : IBackOfficeExternalLoginProvider
var found = _externalLogins.Values.Where(x => x.Options.DenyLocalLogin).ToList();
return found.Count > 0;
}
/// <inheritdoc />
public void InvalidateSessionsIfExternalLoginProvidersChanged()
{
var previousExternalLoginProvidersValue = _keyValueService.GetValue(ExternalLoginProvidersKey);
var currentExternalLoginProvidersValue = string.Join("|", _externalLogins.Keys.OrderBy(key => key));
if ((previousExternalLoginProvidersValue ?? string.Empty) != currentExternalLoginProvidersValue)
{
_logger.LogWarning(
"The configured external login providers have changed. Existing backoffice sessions using the removed providers will be invalidated and external login data removed.");
_externalLoginWithKeyService.PurgeLoginsForRemovedProviders(_externalLogins.Keys);
_keyValueService.SetValue(ExternalLoginProvidersKey, currentExternalLoginProvidersValue);
}
else if (previousExternalLoginProvidersValue is null)
{
_keyValueService.SetValue(ExternalLoginProvidersKey, string.Empty);
}
}
}

View File

@@ -23,4 +23,11 @@ public interface IBackOfficeExternalLoginProviders
/// </summary>
/// <returns></returns>
bool HasDenyLocalLogin();
/// <summary>
/// Used during startup to see if the configured external login providers is different from the persisted information.
/// If they are different, this will invalidate backoffice sessions and clear external logins for removed providers
/// if the external login provider setup has changed.
/// </summary>
void InvalidateSessionsIfExternalLoginProvidersChanged() { }
}

View File

@@ -28,8 +28,8 @@ public class SecuritySettings
internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60;
internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60;
private const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000;
private const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250;
internal const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000;
internal const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250;
internal const string StaticAuthorizeCallbackPathName = "/umbraco/oauth_complete";
internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout";
internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error";

View File

@@ -1,15 +1,17 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Diagnostics.CodeAnalysis;
namespace Umbraco.Extensions;
public static class IntExtensions
{
/// <summary>
/// Does something 'x' amount of times
/// Does something 'x' amount of times.
/// </summary>
/// <param name="n"></param>
/// <param name="action"></param>
/// <param name="n">Number of times to execute the action.</param>
/// <param name="action">The action to execute.</param>
public static void Times(this int n, Action<int> action)
{
for (var i = 0; i < n; i++)
@@ -19,11 +21,11 @@ public static class IntExtensions
}
/// <summary>
/// Creates a Guid based on an integer value
/// Creates a Guid based on an integer value.
/// </summary>
/// <param name="value"><see cref="int" /> value to convert</param>
/// <param name="value">The <see cref="int" /> value to convert.</param>
/// <returns>
/// <see cref="Guid" />
/// The converted <see cref="Guid" />.
/// </returns>
public static Guid ToGuid(this int value)
{
@@ -31,4 +33,25 @@ public static class IntExtensions
BitConverter.GetBytes(value).CopyTo(bytes);
return new Guid(bytes);
}
/// <summary>
/// Restores a GUID previously created from an integer value using <see cref="ToGuid" />.
/// </summary>
/// <param name="value">The <see cref="Guid" /> value to convert.</param>
/// <param name="result">The converted <see cref="int" />.</param>
/// <returns>
/// True if the <see cref="int" /> value could be created, otherwise false.
/// </returns>
public static bool TryParseFromGuid(Guid value, [NotNullWhen(true)] out int? result)
{
if (value.ToString().EndsWith("-0000-0000-0000-000000000000") is false)
{
// We have a proper GUID, not one converted from an integer.
result = null;
return false;
}
result = BitConverter.ToInt32(value.ToByteArray());
return true;
}
}

View File

@@ -1575,4 +1575,22 @@ public static class StringExtensions
// this is by far the fastest way to find string needles in a string haystack
public static int CountOccurrences(this string haystack, string needle)
=> haystack.Length - haystack.Replace(needle, string.Empty).Length;
/// <summary>
/// Verifies the provided string is a valid culture code and returns it in a consistent casing.
/// </summary>
/// <param name="culture">Culture code.</param>
/// <returns>Culture code in standard casing.</returns>
public static string? EnsureCultureCode(this string? culture)
{
if (string.IsNullOrEmpty(culture) || culture == "*")
{
return culture;
}
// Create as CultureInfo instance from provided name so we can ensure consistent casing of culture code when persisting.
// This will accept mixed case but once created have a `Name` property that is consistently and correctly cased.
// Will throw in an invalid culture code is provided.
return new CultureInfo(culture).Name;
}
}

View File

@@ -1,5 +1,7 @@
using System.Collections.Specialized;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.ConstrainedExecution;
using System.Runtime.Serialization;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Extensions;
@@ -288,6 +290,8 @@ public abstract class ContentBase : TreeEntityBase, IContentBase
// set on variant content type
if (ContentType.VariesByCulture())
{
culture = culture.EnsureCultureCode();
// invariant is ok
if (culture.IsNullOrWhiteSpace())
{
@@ -297,7 +301,7 @@ public abstract class ContentBase : TreeEntityBase, IContentBase
// clear
else if (name.IsNullOrWhiteSpace())
{
ClearCultureInfo(culture!);
ClearCultureInfo(culture);
}
// set
@@ -322,11 +326,6 @@ public abstract class ContentBase : TreeEntityBase, IContentBase
private void ClearCultureInfo(string culture)
{
if (culture == null)
{
throw new ArgumentNullException(nameof(culture));
}
if (string.IsNullOrWhiteSpace(culture))
{
throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(culture));
@@ -455,6 +454,7 @@ public abstract class ContentBase : TreeEntityBase, IContentBase
$"No PropertyType exists with the supplied alias \"{propertyTypeAlias}\".");
}
culture = culture.EnsureCultureCode();
var updated = property.SetValue(value, culture, segment);
if (updated)
{

View File

@@ -3,23 +3,29 @@ using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Core.Persistence.Repositories;
/// <summary>
/// Repository for external logins with Guid as key, so it can be shared for members and users
/// Repository for external logins with Guid as key, so it can be shared for members and users.
/// </summary>
public interface IExternalLoginWithKeyRepository : IReadWriteQueryRepository<int, IIdentityUserLogin>,
IQueryRepository<IIdentityUserToken>
{
/// <summary>
/// Replaces all external login providers for the user/member key
/// Replaces all external login providers for the user/member key.
/// </summary>
void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins);
/// <summary>
/// Replaces all external login provider tokens for the providers specified for the user/member key
/// Replaces all external login provider tokens for the providers specified for the user/member key.
/// </summary>
void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens);
/// <summary>
/// Deletes all external logins for the specified the user/member key
/// Deletes all external logins for the specified the user/member key.
/// </summary>
void DeleteUserLogins(Guid userOrMemberKey);
/// <summary>
/// Deletes external logins that aren't associated with the current collection of providers.
/// </summary>
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
}

View File

@@ -1,4 +1,4 @@
using System.Linq.Expressions;
using System.Linq.Expressions;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Persistence.Querying;
@@ -160,4 +160,10 @@ public interface IUserRepository : IReadWriteQueryRepository<Guid, IUser>
bool RemoveClientId(int id, string clientId);
IUser? GetByClientId(string clientId);
/// <summary>
/// Invalidates sessions for users that aren't associated with the current collection of providers.
/// </summary>
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
}

View File

@@ -1,4 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
@@ -12,14 +15,27 @@ public class DataValueReferenceFactoryCollection : BuilderCollectionBase<IDataVa
// TODO: We could further reduce circular dependencies with PropertyEditorCollection by not having IDataValueReference implemented
// by property editors and instead just use the already built in IDataValueReferenceFactory and/or refactor that into a more normal collection
private readonly ILogger<DataValueReferenceFactoryCollection> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DataValueReferenceFactoryCollection" /> class.
/// </summary>
/// <param name="items">The items.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public DataValueReferenceFactoryCollection(Func<IEnumerable<IDataValueReferenceFactory>> items)
: base(items)
: this(
items,
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataValueReferenceFactoryCollection>>())
{ }
/// <summary>
/// Initializes a new instance of the <see cref="DataValueReferenceFactoryCollection" /> class.
/// </summary>
/// <param name="items">The items.</param>
/// <param name="logger">The logger.</param>
public DataValueReferenceFactoryCollection(Func<IEnumerable<IDataValueReferenceFactory>> items, ILogger<DataValueReferenceFactoryCollection> logger)
: base(items) => _logger = logger;
/// <summary>
/// Gets all unique references from the specified properties.
/// </summary>
@@ -33,7 +49,7 @@ public class DataValueReferenceFactoryCollection : BuilderCollectionBase<IDataVa
var references = new HashSet<UmbracoEntityReference>();
// Group by property editor alias to avoid duplicate lookups and optimize value parsing
foreach (var propertyValuesByPropertyEditorAlias in properties.GroupBy(x => x.PropertyType.PropertyEditorAlias, x => x.Values))
foreach (IGrouping<string, IReadOnlyCollection<IPropertyValue>> propertyValuesByPropertyEditorAlias in properties.GroupBy(x => x.PropertyType.PropertyEditorAlias, x => x.Values))
{
if (!propertyEditors.TryGet(propertyValuesByPropertyEditorAlias.Key, out IDataEditor? dataEditor))
{
@@ -48,7 +64,7 @@ public class DataValueReferenceFactoryCollection : BuilderCollectionBase<IDataVa
values.Add(propertyValue.PublishedValue);
}
references.UnionWith(GetReferences(dataEditor, values));
references.UnionWith(GetReferences(dataEditor, values, propertyValuesByPropertyEditorAlias.Key));
}
return references;
@@ -74,14 +90,18 @@ public class DataValueReferenceFactoryCollection : BuilderCollectionBase<IDataVa
/// The references.
/// </returns>
public ISet<UmbracoEntityReference> GetReferences(IDataEditor dataEditor, IEnumerable<object?> values) =>
GetReferencesEnumerable(dataEditor, values).ToHashSet();
private IEnumerable<UmbracoEntityReference> GetReferencesEnumerable(IDataEditor dataEditor, IEnumerable<object?> values)
GetReferencesEnumerable(dataEditor, values, null).ToHashSet();
private ISet<UmbracoEntityReference> GetReferences(IDataEditor dataEditor, IEnumerable<object?> values, string propertyEditorAlias) =>
GetReferencesEnumerable(dataEditor, values, propertyEditorAlias).ToHashSet();
private IEnumerable<UmbracoEntityReference> GetReferencesEnumerable(IDataEditor dataEditor, IEnumerable<object?> values, string? propertyEditorAlias)
{
// TODO: We will need to change this once we support tracking via variants/segments
// for now, we are tracking values from ALL variants
if (dataEditor.GetValueEditor() is IDataValueReference dataValueReference)
{
foreach (UmbracoEntityReference reference in values.SelectMany(dataValueReference.GetReferences))
foreach (UmbracoEntityReference reference in GetReferencesFromPropertyValues(values, dataValueReference, propertyEditorAlias))
{
yield return reference;
}
@@ -107,6 +127,38 @@ public class DataValueReferenceFactoryCollection : BuilderCollectionBase<IDataVa
}
}
private IEnumerable<UmbracoEntityReference> GetReferencesFromPropertyValues(IEnumerable<object?> values, IDataValueReference dataValueReference, string? propertyEditorAlias)
{
var result = new List<UmbracoEntityReference>();
foreach (var value in values)
{
// When property editors on data types are changed, we could have values that are incompatible with the new editor.
// Leading to issues such as:
// - https://github.com/umbraco/Umbraco-CMS/issues/17628
// - https://github.com/umbraco/Umbraco-CMS/issues/17725
// Although some changes like this are not intended to be compatible, we should handle them gracefully and not
// error in retrieving references, which would prevent manipulating or deleting the content that uses the data type.
try
{
IEnumerable<UmbracoEntityReference> references = dataValueReference.GetReferences(value);
result.AddRange(references);
}
catch (Exception ex)
{
// Log the exception but don't throw, continue with the next value.
_logger.LogError(
ex,
"Error getting references from value {Value} with data editor {DataEditor} and property editor alias {PropertyEditorAlias}.",
value,
dataValueReference.GetType().FullName,
propertyEditorAlias ?? "n/a");
throw;
}
}
return result;
}
/// <summary>
/// Gets all relation type aliases that are automatically tracked.
/// </summary>
@@ -114,6 +166,10 @@ public class DataValueReferenceFactoryCollection : BuilderCollectionBase<IDataVa
/// <returns>
/// All relation type aliases that are automatically tracked.
/// </returns>
[Obsolete("Use GetAllAutomaticRelationTypesAliases. This will be removed in Umbraco 15.")]
public ISet<string> GetAutomaticRelationTypesAliases(PropertyEditorCollection propertyEditors) =>
GetAllAutomaticRelationTypesAliases(propertyEditors);
public ISet<string> GetAllAutomaticRelationTypesAliases(PropertyEditorCollection propertyEditors)
{
// Always add default automatic relation types

View File

@@ -264,7 +264,16 @@ public sealed class AuditService : RepositoryService, IAuditService
}
/// <inheritdoc />
public IAuditEntry Write(int performingUserId, string perfomingDetails, string performingIp, DateTime eventDateUtc, int affectedUserId, string? affectedDetails, string eventType, string eventDetails)
[Obsolete("Will be moved to a new service in V17. Scheduled for removal in V18.")]
public IAuditEntry Write(
int performingUserId,
string perfomingDetails,
string performingIp,
DateTime eventDateUtc,
int affectedUserId,
string? affectedDetails,
string eventType,
string eventDetails)
{
if (performingUserId < 0 && performingUserId != Constants.Security.SuperUserId)
{

View File

@@ -1184,6 +1184,8 @@ public class ContentService : RepositoryService, IContentService
throw new ArgumentException("Cultures cannot be null or whitespace", nameof(cultures));
}
cultures = cultures.Select(x => x.EnsureCultureCode()!).ToArray();
EventMessages evtMsgs = EventMessagesFactory.Get();
// we need to guard against unsaved changes before proceeding; the content will be saved, but we're not firing any saved notifications
@@ -1263,7 +1265,7 @@ public class ContentService : RepositoryService, IContentService
EventMessages evtMsgs = EventMessagesFactory.Get();
culture = culture?.NullOrWhiteSpaceAsNull();
culture = culture?.NullOrWhiteSpaceAsNull().EnsureCultureCode();
PublishedState publishedState = content.PublishedState;
if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished)
@@ -2063,7 +2065,7 @@ public class ContentService : RepositoryService, IContentService
cultures = ["*"];
}
return cultures;
return cultures.Select(x => x.EnsureCultureCode()!).ToArray();
}
private static bool ProvidedCulturesIndicatePublishAll(string[] cultures) => cultures.Length == 0 || (cultures.Length == 1 && cultures[0] == "invariant");

View File

@@ -5,21 +5,40 @@ using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
public class ExternalLoginService : RepositoryService, IExternalLoginWithKeyService
{
private readonly IExternalLoginWithKeyRepository _externalLoginRepository;
private readonly IUserRepository _userRepository;
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public ExternalLoginService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory,
IExternalLoginWithKeyRepository externalLoginRepository)
: base(provider, loggerFactory, eventMessagesFactory) =>
: this(
provider,
loggerFactory,
eventMessagesFactory,
externalLoginRepository,
StaticServiceProvider.Instance.GetRequiredService<IUserRepository>())
{
}
public ExternalLoginService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory,
IExternalLoginWithKeyRepository externalLoginRepository,
IUserRepository userRepository)
: base(provider, loggerFactory, eventMessagesFactory)
{
_externalLoginRepository = externalLoginRepository;
_userRepository = userRepository;
}
public IEnumerable<IIdentityUserLogin> Find(string loginProvider, string providerKey)
{
@@ -80,4 +99,15 @@ public class ExternalLoginService : RepositoryService, IExternalLoginWithKeyServ
scope.Complete();
}
}
/// <inheritdoc />
public void PurgeLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
_userRepository.InvalidateSessionsForRemovedProviders(currentLoginProviders);
_externalLoginRepository.DeleteUserLoginsForRemovedProviders(currentLoginProviders);
scope.Complete();
}
}
}

View File

@@ -144,6 +144,7 @@ public interface IAuditService : IService
/// </example>
/// </param>
/// <param name="eventDetails">Free-form details about the audited event.</param>
[Obsolete("Will be moved to a new service in V17. Scheduled for removal in V18.")]
IAuditEntry Write(
int performingUserId,
string perfomingDetails,

View File

@@ -5,47 +5,53 @@ namespace Umbraco.Cms.Core.Services;
public interface IExternalLoginWithKeyService : IService
{
/// <summary>
/// Returns all user logins assigned
/// Returns all user logins assigned.
/// </summary>
IEnumerable<IIdentityUserLogin> GetExternalLogins(Guid userOrMemberKey);
/// <summary>
/// Returns all user login tokens assigned
/// Returns all user login tokens assigned.
/// </summary>
IEnumerable<IIdentityUserToken> GetExternalLoginTokens(Guid userOrMemberKey);
/// <summary>
/// Returns all logins matching the login info - generally there should only be one but in some cases
/// there might be more than one depending on if an administrator has been editing/removing members
/// there might be more than one depending on if an administrator has been editing/removing members.
/// </summary>
IEnumerable<IIdentityUserLogin> Find(string loginProvider, string providerKey);
/// <summary>
/// Saves the external logins associated with the user
/// Saves the external logins associated with the user.
/// </summary>
/// <param name="userOrMemberKey">
/// The user or member key associated with the logins
/// The user or member key associated with the logins.
/// </param>
/// <param name="logins"></param>
/// <remarks>
/// This will replace all external login provider information for the user
/// This will replace all external login provider information for the user.
/// </remarks>
void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins);
/// <summary>
/// Saves the external login tokens associated with the user
/// Saves the external login tokens associated with the user.
/// </summary>
/// <param name="userOrMemberKey">
/// The user or member key associated with the logins
/// The user or member key associated with the logins.
/// </param>
/// <param name="tokens"></param>
/// <remarks>
/// This will replace all external login tokens for the user
/// This will replace all external login tokens for the user.
/// </remarks>
void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens);
/// <summary>
/// Deletes all user logins - normally used when a member is deleted
/// Deletes all user logins - normally used when a member is deleted.
/// </summary>
void DeleteUserLogins(Guid userOrMemberKey);
/// <summary>
/// Deletes external logins and invalidates sessions for users that aren't associated with the current collection of providers.
/// </summary>
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
void PurgeLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
}

View File

@@ -35,7 +35,7 @@ public interface IIndexedEntitySearchService
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)
=> Search(objectType,query, skip, take, ignoreUserStartNodes);
=> Search(objectType, query, skip, take, ignoreUserStartNodes);
Task<PagedModel<IEntitySlim>> SearchAsync(
UmbracoObjectTypes objectType,
@@ -43,6 +43,7 @@ public interface IIndexedEntitySearchService
Guid? parentId,
IEnumerable<Guid>? contentTypeIds,
bool? trashed,
string? culture = null,
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false);

View File

@@ -7,7 +7,11 @@ namespace Umbraco.Cms.Core.Templates;
public sealed class HtmlImageSourceParser
{
private static readonly Regex ResolveImgPattern = new(
@"(<img[^>]*src="")([^""\?]*)((?:\?[^""]*)?""[^>]*data-udi="")([^""]*)(""[^>]*>)",
@"<img[^>]*(data-udi=""([^""]*)"")[^>]*>",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
private static readonly Regex SrcAttributeRegex = new(
@"src=""([^""\?]*)(\?[^""]*)?""",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
private static readonly Regex DataUdiAttributeRegex = new(
@@ -61,17 +65,26 @@ public sealed class HtmlImageSourceParser
return ResolveImgPattern.Replace(text, match =>
{
// match groups:
// - 1 = from the beginning of the image tag until src attribute value begins
// - 2 = the src attribute value excluding the querystring (if present)
// - 3 = anything after group 2 and before the data-udi attribute value begins
// - 4 = the data-udi attribute value
// - 5 = anything after group 4 until the image tag is closed
var udi = match.Groups[4].Value;
// - 1 = the data-udi attribute
// - 2 = the data-udi attribute value
var udi = match.Groups[2].Value;
if (udi.IsNullOrWhiteSpace() || UdiParser.TryParse<GuidUdi>(udi, out GuidUdi? guidUdi) == false)
{
return match.Value;
}
// Find the src attribute
// src match groups:
// - 1 = the src attribute value until the query string
// - 2 = the src attribute query string including the '?'
Match src = SrcAttributeRegex.Match(match.Value);
if (src.Success == false)
{
// the src attribute isn't found, return the original value
return match.Value;
}
var mediaUrl = _getMediaUrl(guidUdi.Guid);
if (mediaUrl == null)
{
@@ -80,7 +93,9 @@ public sealed class HtmlImageSourceParser
return match.Value;
}
return $"{match.Groups[1].Value}{mediaUrl}{match.Groups[3].Value}{udi}{match.Groups[5].Value}";
var newImgTag = match.Value.Replace(src.Value, $"src=\"{mediaUrl}{src.Groups[2].Value}\"");
return newImgTag;
});
}
@@ -91,6 +106,16 @@ public sealed class HtmlImageSourceParser
/// <returns></returns>
public string RemoveImageSources(string text)
// see comment in ResolveMediaFromTextString for group reference
=> ResolveImgPattern.Replace(text, "$1$3$4$5");
// find each ResolveImgPattern match in the text, then find each
// SrcAttributeRegex match in the match value, then replace the src
// attribute value with an empty string
// (see comment in ResolveMediaFromTextString for group reference)
=> ResolveImgPattern.Replace(text, match =>
{
// Find the src attribute
Match src = SrcAttributeRegex.Match(match.Value);
return src.Success == false || string.IsNullOrWhiteSpace(src.Groups[1].Value) ?
match.Value : match.Value.Replace(src.Groups[1].Value, string.Empty);
});
}

View File

@@ -248,6 +248,8 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IRichTextRequiredValidator, RichTextRequiredValidator>();
builder.Services.AddSingleton<IRichTextRegexValidator, RichTextRegexValidator>();
return builder;
}

View File

@@ -56,6 +56,12 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
public void DeleteUserLogins(Guid userOrMemberKey) =>
Database.Delete<ExternalLoginDto>("WHERE userOrMemberKey=@userOrMemberKey", new { userOrMemberKey });
/// <inheritdoc />
public void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) =>
Database.Execute(Sql()
.Delete<ExternalLoginDto>()
.WhereNotIn<ExternalLoginDto>(x => x.LoginProvider, currentLoginProviders));
/// <inheritdoc />
public void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins)
{

View File

@@ -12,7 +12,6 @@ using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
@@ -21,7 +20,6 @@ using Umbraco.Cms.Infrastructure.Persistence.Mappers;
using Umbraco.Cms.Infrastructure.Persistence.Querying;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Extensions;
using IScope = Umbraco.Cms.Infrastructure.Scoping.IScope;
namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
/// <summary>
@@ -1268,5 +1266,32 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0
return sql;
}
/// <inheritdoc />
public void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentLoginProviders)
{
// Get all the user or member keys associated with the removed providers.
Sql<ISqlContext> idsQuery = SqlContext.Sql()
.Select<ExternalLoginDto>(x => x.UserOrMemberKey)
.From<ExternalLoginDto>()
.WhereNotIn<ExternalLoginDto>(x => x.LoginProvider, currentLoginProviders);
List<Guid> userAndMemberKeysAssociatedWithRemovedProviders = Database.Fetch<Guid>(idsQuery);
if (userAndMemberKeysAssociatedWithRemovedProviders.Count == 0)
{
return;
}
// Invalidate the security stamps on the users associated with the removed providers.
Sql<ISqlContext> updateSecurityStampsQuery = Sql()
.Update<UserDto>(u => u.Set(x => x.SecurityStampToken, "0".PadLeft(32, '0')))
.WhereIn<UserDto>(x => x.Key, userAndMemberKeysAssociatedWithRemovedProviders);
Database.Execute(updateSecurityStampsQuery);
// Delete the OpenIddict tokens for the users associated with the removed providers.
// The following is safe from SQL injection as we are dealing with GUIDs, not strings.
var userKeysForInClause = string.Join("','", userAndMemberKeysAssociatedWithRemovedProviders.Select(x => x.ToString()));
Database.Execute("DELETE FROM umbracoOpenIddictTokens WHERE Subject IN ('" + userKeysForInClause + "')");
}
#endregion
}

View File

@@ -71,17 +71,17 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
continue;
}
var districtValues = valuesByPropertyEditorAlias.Distinct().ToArray();
var distinctValues = valuesByPropertyEditorAlias.Distinct().ToArray();
if (dataEditor.GetValueEditor() is IDataValueReference reference)
{
foreach (UmbracoEntityReference value in districtValues.SelectMany(reference.GetReferences))
foreach (UmbracoEntityReference value in distinctValues.SelectMany(reference.GetReferences))
{
result.Add(value);
}
}
IEnumerable<UmbracoEntityReference> references = _dataValueReferenceFactoryCollection.GetReferences(dataEditor, districtValues);
IEnumerable<UmbracoEntityReference> references = _dataValueReferenceFactoryCollection.GetReferences(dataEditor, distinctValues);
foreach (UmbracoEntityReference value in references)
{

View File

@@ -93,6 +93,7 @@ public class RichTextPropertyEditor : DataEditor
private readonly RichTextEditorPastedImages _pastedImages;
private readonly IJsonSerializer _jsonSerializer;
private readonly IRichTextRequiredValidator _richTextRequiredValidator;
private readonly IRichTextRegexValidator _richTextRegexValidator;
private readonly ILogger<RichTextPropertyValueEditor> _logger;
public RichTextPropertyValueEditor(
@@ -111,6 +112,7 @@ public class RichTextPropertyEditor : DataEditor
IPropertyValidationService propertyValidationService,
DataValueReferenceFactoryCollection dataValueReferenceFactoryCollection,
IRichTextRequiredValidator richTextRequiredValidator,
IRichTextRegexValidator richTextRegexValidator,
BlockEditorVarianceHandler blockEditorVarianceHandler,
ILanguageService languageService,
IIOHelper ioHelper)
@@ -122,6 +124,7 @@ public class RichTextPropertyEditor : DataEditor
_pastedImages = pastedImages;
_htmlSanitizer = htmlSanitizer;
_richTextRequiredValidator = richTextRequiredValidator;
_richTextRegexValidator = richTextRegexValidator;
_jsonSerializer = jsonSerializer;
_logger = logger;
@@ -131,6 +134,8 @@ public class RichTextPropertyEditor : DataEditor
public override IValueRequiredValidator RequiredValidator => _richTextRequiredValidator;
public override IValueFormatValidator FormatValidator => _richTextRegexValidator;
protected override RichTextBlockValue CreateWithLayout(IEnumerable<RichTextBlockLayoutItem> layout) => new(layout);
/// <inheritdoc />

View File

@@ -0,0 +1,5 @@
namespace Umbraco.Cms.Core.PropertyEditors.Validators;
internal interface IRichTextRegexValidator : IValueFormatValidator
{
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Serialization;
namespace Umbraco.Cms.Core.PropertyEditors.Validators;
internal class RichTextRegexValidator : IRichTextRegexValidator
{
private readonly RegexValidator _regexValidator;
private readonly IJsonSerializer _jsonSerializer;
private readonly ILogger<RichTextRegexValidator> _logger;
public RichTextRegexValidator(
IJsonSerializer jsonSerializer,
ILogger<RichTextRegexValidator> logger)
{
_jsonSerializer = jsonSerializer;
_logger = logger;
_regexValidator = new RegexValidator();
}
public IEnumerable<ValidationResult> ValidateFormat(object? value, string? valueType, string format) => _regexValidator.ValidateFormat(GetValue(value), valueType, format);
private object? GetValue(object? value) =>
RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out RichTextEditorValue? richTextEditorValue)
? richTextEditorValue?.Markup
: value;
}

View File

@@ -73,7 +73,7 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)
=> SearchAsync(objectType, query, parentId, contentTypeIds, trashed, skip, take, ignoreUserStartNodes).GetAwaiter().GetResult();
=> SearchAsync(objectType, query, parentId, contentTypeIds, trashed, null, skip, take, ignoreUserStartNodes).GetAwaiter().GetResult();
public Task<PagedModel<IEntitySlim>> SearchAsync(
UmbracoObjectTypes objectType,
@@ -81,6 +81,7 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
Guid? parentId,
IEnumerable<Guid>? contentTypeIds,
bool? trashed,
string? culture = null,
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)

View File

@@ -326,8 +326,9 @@ public class PackagingService : IPackagingService
PackageName = group.Key.PackageName,
};
var packageKey = Constants.Conventions.Migrations.KeyValuePrefix + (group.Key.PackageId ?? group.Key.PackageName);
var currentState = keyValues?
.GetValueOrDefault(Constants.Conventions.Migrations.KeyValuePrefix + group.Key.PackageId);
.GetValueOrDefault(packageKey);
package.PackageMigrationPlans = group
.Select(plan => new InstalledPackageMigrationPlans

View File

@@ -31,7 +31,7 @@ public class ConfigureSecurityStampOptions : IConfigureOptions<SecurityStampVali
// Adjust the security stamp validation interval to a shorter duration
// when concurrent logins are not allowed and the duration has the default interval value
// (currently defaults to 30 minutes), ensuring quicker re-validation.
if (securitySettings.AllowConcurrentLogins is false && options.ValidationInterval == TimeSpan.FromMinutes(30))
if (securitySettings.AllowConcurrentLogins is false && options.ValidationInterval == new SecurityStampValidatorOptions().ValidationInterval)
{
options.ValidationInterval = TimeSpan.FromSeconds(30);
}

View File

@@ -29,7 +29,9 @@ export class ExampleBlockCustomView extends UmbElementMixin(LitElement) implemen
UmbTextStyles,
css`
:host {
position: relative;
display: block;
z-index: 10000;
height: 100%;
box-sizing: border-box;
background-color: red;
@@ -38,6 +40,12 @@ export class ExampleBlockCustomView extends UmbElementMixin(LitElement) implemen
padding: 12px;
}
:host > div {
position: relative;
display: block;
z-index: 10000;
}
.align-center {
text-align: center;
}

View File

@@ -62,8 +62,9 @@ export default {
unlock: 'Lås op',
createblueprint: 'Opret indholdsskabelon',
resendInvite: 'Gensend Invitation',
editContent: 'Edit content',
chooseWhereToImport: 'Choose where to import',
editContent: 'Rediger indhold',
chooseWhereToImport: 'Vælg hvor du vil importere',
viewActionsFor: (name) => (name ? `Se handlinger for '${name}'` : 'Se handlinger'),
},
actionCategories: {
content: 'Indhold',

View File

@@ -72,6 +72,7 @@ export default {
wasCopiedTo: 'was copied to',
wasDeleted: 'was deleted',
wasMovedTo: 'was moved to',
viewActionsFor: (name) => (name ? `View actions for '${name}'` : 'View actions'),
},
actionCategories: {
content: 'Content',
@@ -159,7 +160,7 @@ export default {
saveAndPublish: 'Save and publish',
saveToPublish: 'Save and send for approval',
saveListView: 'Save list view',
schedulePublish: 'Schedule',
schedulePublish: 'Schedule publish',
saveAndPreview: 'Save and preview',
showPageDisabled: "Preview is disabled because there's no template assigned",
styleChoose: 'Choose style',
@@ -271,7 +272,7 @@ export default {
publishedPendingChanges: 'Published (pending changes)',
publishStatus: 'Publication Status',
publishDescendantsHelp:
'Publish <strong>%0%</strong> and all content items underneath and thereby making their content publicly available.',
'Publish <strong>%0%</strong> and all items underneath and thereby making their content publicly available.',
publishDescendantsWithVariantsHelp:
'Publish variants and variants of same type underneath and thereby making their content publicly available.',
noVariantsToProcess: 'There are no available variants',
@@ -319,7 +320,7 @@ export default {
addTextBox: 'Add another text box',
removeTextBox: 'Remove this text box',
contentRoot: 'Content root',
includeUnpublished: 'Include unpublished content items.',
includeUnpublished: 'Include unpublished items.',
isSensitiveValue:
'This value is hidden. If you need access to view this value please contact your website administrator.',
isSensitiveValue_short: 'This value is hidden.',
@@ -348,6 +349,8 @@ export default {
variantUnpublishNotAllowed: 'Unpublish is not allowed',
selectAllVariants: 'Select all variants',
saveModalTitle: 'Save',
saveAndPublishModalTitle: 'Save and publish',
publishModalTitle: 'Publish',
},
blueprints: {
createBlueprintFrom: "Create a new Document Blueprint from '%0%'",
@@ -475,10 +478,11 @@ export default {
discardChanges: 'Discard changes',
unsavedChanges: 'Discard unsaved changes',
unsavedChangesWarning: 'Are you sure you want to navigate away from this page? You have unsaved changes',
confirmListViewPublish: 'Publishing will make the selected items visible on the site.',
confirmListViewUnpublish: 'Unpublishing will remove the selected items and all their descendants from the site.',
confirmPublish: 'Publishing will make this page and all its published descendants visible on the site.',
confirmUnpublish: 'Unpublishing will remove this page and all its descendants from the site.',
confirmListViewPublish: 'Publishing will make the selected items publicly available.',
confirmListViewUnpublish:
'Unpublishing will make the selected items and all their descendants publicly unavailable.',
confirmPublish: 'Publishing will make this content and all its published descendants publicly available.',
confirmUnpublish: 'Unpublishing will make this content publicly unavailable.',
doctypeChangeWarning: 'You have unsaved changes. Making changes to the Document Type will discard the changes.',
},
bulk: {
@@ -911,7 +915,7 @@ export default {
retrieve: 'Retrieve',
retry: 'Retry',
rights: 'Permissions',
scheduledPublishing: 'Scheduled Publishing',
scheduledPublishing: 'Schedule publish',
umbracoInfo: 'Umbraco info',
search: 'Search',
searchNoResult: 'Sorry, we can not find what you are looking for.',
@@ -2795,7 +2799,7 @@ export default {
modalSource: 'Source',
modalManual: 'Manual',
modalAnchorValidationMessage:
'Please enter an anchor or querystring, or select a published document or media item, or manually configure the URL.',
'Please enter an anchor or querystring, select a document or media item, or manually configure the URL.',
resetUrlHeadline: 'Reset URL?',
resetUrlMessage: 'Are you sure you want to reset this URL?',
resetUrlLabel: 'Reset',

View File

@@ -84,7 +84,7 @@ export class UmbArrayState<T, U = unknown> extends UmbDeepState<T[]> {
/**
* @function remove
* @param {unknown[]} uniques - The unique values to remove.
* @param {U[]} uniques - The unique values to remove.
* @returns {UmbArrayState<T>} Reference to it self.
* @description - Remove some new data of this Subject.
* @example <caption>Example remove entry with id '1' and '2'</caption>
@@ -95,7 +95,7 @@ export class UmbArrayState<T, U = unknown> extends UmbDeepState<T[]> {
* const myState = new UmbArrayState(data, (x) => x.id);
* myState.remove([1, 2]);
*/
remove(uniques: unknown[]) {
remove(uniques: U[]) {
if (this.getUniqueMethod) {
let next = this.getValue();
if (!next) return this;
@@ -114,7 +114,7 @@ export class UmbArrayState<T, U = unknown> extends UmbDeepState<T[]> {
/**
* @function removeOne
* @param {unknown} unique - The unique value to remove.
* @param {U} unique - The unique value to remove.
* @returns {UmbArrayState<T>} Reference to it self.
* @description - Remove some new data of this Subject.
* @example <caption>Example remove entry with id '1'</caption>
@@ -125,7 +125,7 @@ export class UmbArrayState<T, U = unknown> extends UmbDeepState<T[]> {
* const myState = new UmbArrayState(data, (x) => x.id);
* myState.removeOne(1);
*/
removeOne(unique: unknown) {
removeOne(unique: U) {
if (this.getUniqueMethod) {
let next = this.getValue();
if (!next) return this;
@@ -251,7 +251,7 @@ export class UmbArrayState<T, U = unknown> extends UmbDeepState<T[]> {
/**
* @function updateOne
* @param {unknown} unique - Unique value to find entry to update.
* @param {U} unique - Unique value to find entry to update.
* @param {Partial<T>} entry - new data to be added in this Subject.
* @returns {UmbArrayState<T>} Reference to it self.
* @description - Update a item with some new data, requires the ArrayState to be constructed with a getUnique method.
@@ -263,7 +263,7 @@ export class UmbArrayState<T, U = unknown> extends UmbDeepState<T[]> {
* const myState = new UmbArrayState(data, (x) => x.key);
* myState.updateOne(2, {value: 'updated-bar'});
*/
updateOne(unique: unknown, entry: Partial<T>) {
updateOne(unique: U, entry: Partial<T>) {
if (!this.getUniqueMethod) {
throw new Error("Can't partial update an ArrayState without a getUnique method provided when constructed.");
}

View File

@@ -427,6 +427,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
#extensionSlotRenderMethod = (ext: UmbExtensionElementInitializer<ManifestBlockEditorCustomView>) => {
if (ext.component) {
ext.component.classList.add('umb-block-grid__block--view');
ext.component.setAttribute('part', 'component');
}
if (this._exposed) {
return ext.component;
@@ -641,6 +642,11 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
border-color: var(--uui-color-invalid);
}
umb-extension-slot::part(component) {
position: relative;
z-index: 0;
}
#invalidLocation {
position: absolute;
top: -1em;

View File

@@ -346,6 +346,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
};
#extensionSlotRenderMethod = (ext: UmbExtensionElementInitializer<ManifestBlockEditorCustomView>) => {
ext.component?.setAttribute('part', 'component');
if (this._exposed) {
return ext.component;
} else {
@@ -511,6 +512,11 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
border-color: var(--uui-color-invalid);
}
umb-extension-slot::part(component) {
position: relative;
z-index: 0;
}
uui-action-bar {
position: absolute;
top: var(--uui-size-2);

View File

@@ -243,6 +243,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
};
#extensionSlotRenderMethod = (ext: UmbExtensionElementInitializer<ManifestBlockEditorCustomView>) => {
ext.component?.setAttribute('part', 'component');
if (this._exposed) {
return ext.component;
} else {
@@ -345,6 +346,12 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
outline: 3px solid var(--uui-color-focus);
}
}
umb-extension-slot::part(component) {
position: relative;
z-index: 0;
}
uui-action-bar {
position: absolute;
top: var(--uui-size-2);

View File

@@ -39,7 +39,7 @@ export class UmbBlockWorkspaceViewEditPropertiesElement extends UmbLitElement {
_dataOwner?: UmbBlockElementManager;
@state()
_variantId?: UmbVariantId;
_workspaceVariantId?: UmbVariantId;
@state()
_visibleProperties?: Array<UmbPropertyTypeModel>;
@@ -56,7 +56,7 @@ export class UmbBlockWorkspaceViewEditPropertiesElement extends UmbLitElement {
this.observe(
workspaceContext?.variantId,
(variantId) => {
this._variantId = variantId;
this._workspaceVariantId = variantId;
this.#processPropertyStructure();
},
'observeVariantId',
@@ -83,16 +83,19 @@ export class UmbBlockWorkspaceViewEditPropertiesElement extends UmbLitElement {
}
#processPropertyStructure() {
if (!this._dataOwner || !this.#properties || !this.#propertyStructureHelper) {
if (!this._dataOwner || !this.#properties || !this.#propertyStructureHelper || !this._workspaceVariantId) {
return;
}
const propertyViewGuard = this._dataOwner.propertyViewGuard;
this.#properties.forEach((property) => {
const propertyVariantId = new UmbVariantId(this._variantId?.culture, this._variantId?.segment);
const propertyVariantId = new UmbVariantId(
property.variesByCulture ? this._workspaceVariantId!.culture : null,
property.variesBySegment ? this._workspaceVariantId!.segment : null,
);
this.observe(
propertyViewGuard.isPermittedForVariantAndProperty(propertyVariantId, property),
propertyViewGuard.isPermittedForVariantAndProperty(propertyVariantId, property, this._workspaceVariantId!),
(permitted) => {
if (permitted) {
this.#visiblePropertiesUniques.push(property.unique);
@@ -117,7 +120,7 @@ export class UmbBlockWorkspaceViewEditPropertiesElement extends UmbLitElement {
}
override render() {
return this._variantId && this._visibleProperties
return this._workspaceVariantId && this._visibleProperties
? repeat(
this._visibleProperties,
(property) => property.alias,
@@ -126,7 +129,7 @@ export class UmbBlockWorkspaceViewEditPropertiesElement extends UmbLitElement {
class="property"
.ownerContext=${this._dataOwner}
.ownerEntityType=${this._ownerEntityType}
.variantId=${this._variantId}
.variantId=${this._workspaceVariantId}
.property=${property}></umb-block-workspace-view-edit-property>`,
)
: nothing;

View File

@@ -38,7 +38,11 @@ export class UmbBlockWorkspaceViewEditPropertyElement extends UmbLitElement {
})}].value`;
this.observe(
this.ownerContext.propertyWriteGuard.isPermittedForVariantAndProperty(propertyVariantId, this.property),
this.ownerContext.propertyWriteGuard.isPermittedForVariantAndProperty(
propertyVariantId,
this.property,
this.variantId,
),
(write) => {
this._writeable = write;
},

View File

@@ -150,7 +150,7 @@ export class UmbClipboardEntryPickerElement extends UmbLitElement {
slot="actions"
.entityType=${item.entityType}
.unique=${item.unique}
.label=${item.name}>
.label=${this.localize.term('actions_viewActionsFor', [item.name])}>
</umb-entity-actions-bundle>
`;
}

View File

@@ -39,6 +39,9 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
@state()
private _usedForInheritance: Array<string> = [];
@state()
private _usedForComposition: Array<string> = [];
override connectedCallback() {
super.connectedCallback();
@@ -53,6 +56,7 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
this._selection = this.data?.selection ?? [];
this._usedForInheritance = this.data?.usedForInheritance ?? [];
this._usedForComposition = this.data?.usedForComposition ?? [];
this.modalContext?.setValue({ selection: this._selection });
const isNew = this.data!.isNew;
@@ -131,7 +135,9 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
override render() {
return html`
<umb-body-layout headline="${this.localize.term('contentTypeEditor_compositions')}">
<uui-box>
${this._references.length ? this.#renderHasReference() : this.#renderAvailableCompositions()}
</uui-box>
<div slot="actions">
<uui-button label=${this.localize.term('general_close')} @click=${this._rejectModal}></uui-button>
${!this._references.length
@@ -213,11 +219,16 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
(compositions) => compositions.unique,
(compositions) => {
const usedForInheritance = this._usedForInheritance.includes(compositions.unique);
const usedForComposition = this._usedForComposition.includes(compositions.unique);
/* The server will return isCompatible as false if the Doc Type is currently being used in a composition.
Therefore, we need to account for this in the "isDisabled" check to ensure it remains enabled.
Otherwise, it would become disabled and couldn't be deselected by the user. */
const isDisabled = usedForInheritance || (compositions.isCompatible === false && !usedForComposition);
return html`
<uui-menu-item
label=${this.localize.string(compositions.name)}
?selectable=${!usedForInheritance}
?disabled=${usedForInheritance}
?disabled=${isDisabled}
@selected=${() => this.#onSelectionAdd(compositions.unique)}
@deselected=${() => this.#onSelectionRemove(compositions.unique)}
?selected=${this._selection.find((unique) => unique === compositions.unique)}>
@@ -251,6 +262,10 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
align-items: center;
gap: var(--uui-size-3);
}
.compositions-list {
margin-block: var(--uui-size-3);
}
`,
];
}

View File

@@ -5,6 +5,7 @@ export interface UmbCompositionPickerModalData {
compositionRepositoryAlias: string;
selection: Array<string>;
usedForInheritance: Array<string>;
usedForComposition: Array<string>;
unique: string | null;
isElement: boolean;
currentPropertyAliases: Array<string>;

View File

@@ -194,6 +194,7 @@ export class UmbContentTypeContainerStructureHelper<T extends UmbContentTypeMode
// For that we get the owner containers first (We do not need to observe as this observation will be triggered if one of the owner containers change) [NL]
this.#ownerChildContainers = this.#structure!.getOwnerContainers(this.#childType!, this.#containerId!) ?? [];
this.#childContainers.setValue(rootContainers);
},
'_observeRootContainers',

View File

@@ -24,6 +24,7 @@ import { incrementString } from '@umbraco-cms/backoffice/utils';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry, type ManifestRepository } from '@umbraco-cms/backoffice/extension-registry';
import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs';
type UmbPropertyTypeUnique = UmbPropertyTypeModel['unique'];
@@ -114,12 +115,8 @@ export class UmbContentTypeStructureManager<
readonly variesByCulture = createObservablePart(this.ownerContentType, (x) => x?.variesByCulture);
readonly variesBySegment = createObservablePart(this.ownerContentType, (x) => x?.variesBySegment);
#containers: UmbArrayState<UmbPropertyTypeContainerModel> = new UmbArrayState<UmbPropertyTypeContainerModel>(
[],
(x) => x.id,
);
containerById(id: string) {
return this.#containers.asObservablePart((x) => x.find((y) => y.id === id));
return createObservablePart(this.#contentTypeContainers, (x) => x.find((y) => y.id === id));
}
constructor(host: UmbControllerHost, typeRepository: UmbDetailRepository<T> | string) {
@@ -143,11 +140,20 @@ export class UmbContentTypeStructureManager<
this.#repoManager.entries,
(entries) => {
// Prevent updating once that are have edited here.
entries = entries.filter(
const entriesToBeUpdated = entries.filter(
(x) => !(this.#editedTypes.getHasOne(x.unique) && this.#contentTypes.getHasOne(x.unique)),
);
this.#contentTypes.append(entries);
// Remove entries based on no-longer existing uniques:
const entriesToBeRemoved = this.#contentTypes
.getValue()
.filter((entry) => !entries.some((x) => x.unique === entry.unique))
.map((x) => x.unique);
this.#contentTypes.mute();
this.#contentTypes.remove(entriesToBeRemoved);
this.#contentTypes.append(entriesToBeUpdated);
this.#contentTypes.unmute();
},
null,
);
@@ -161,13 +167,6 @@ export class UmbContentTypeStructureManager<
},
null,
);
this.observe(
this.#contentTypeContainers,
(contentTypeContainers) => {
this.#containers.setValue(contentTypeContainers);
},
null,
);
}
/**
@@ -266,6 +265,8 @@ export class UmbContentTypeStructureManager<
}
async #loadContentTypeCompositions(contentTypeCompositions: T['compositions'] | undefined) {
// Important to wait a JS-cycle, cause this is called by an observation of a state and this results in setting the value for the state(potentially in the same JS-cycle) then we need to make sure we don't trigger a new update before the old subscription chain is completed. [NL]
await Promise.resolve();
const ownerUnique = this.getOwnerContentTypeUnique();
if (!ownerUnique) return;
const compositionUniques = contentTypeCompositions?.map((x) => x.contentType.unique) ?? [];
@@ -359,7 +360,7 @@ export class UmbContentTypeStructureManager<
this.#editedTypes.appendOne(toContentTypeUnique);
// Find container.
const container = this.#containers.getValue().find((x) => x.id === containerId);
const container = (await firstValueFrom(this.#contentTypeContainers)).find((x) => x.id === containerId);
if (!container) throw new Error('Container to clone was not found');
const clonedContainer: UmbPropertyTypeContainerModel = {
@@ -433,38 +434,37 @@ export class UmbContentTypeStructureManager<
sortOrder: sortOrder ?? 0,
};
// Ensure
this.ensureContainerNames(contentTypeUnique, type, parentId);
const contentTypes = this.#contentTypes.getValue();
const containers = [...(contentTypes.find((x) => x.unique === contentTypeUnique)?.containers ?? [])];
containers.push(container);
this.#contentTypes.updateOne(contentTypeUnique, { containers } as Partial<T>);
return container;
return this.insertContainer(contentTypeUnique, container);
}
/*async insertContainer(contentTypeUnique: string | null, container: UmbPropertyTypeContainerModel) {
async insertContainer(contentTypeUnique: string | null, container: UmbPropertyTypeContainerModel) {
await this.#init;
contentTypeUnique = contentTypeUnique ?? this.#ownerContentTypeUnique!;
const newContainer = { ...container };
const type = newContainer.type;
const parentId = newContainer.parent?.id ?? null;
// If we have a parent, we need to ensure it exists, and then update the parent property with the new container id.
if (container.parent) {
const parentContainer = await this.ensureContainerOf(container.parent.id, contentTypeUnique);
if (newContainer.parent) {
const parentContainer = await this.ensureContainerOf(newContainer.parent.id, contentTypeUnique);
if (!parentContainer) {
throw new Error('Container for inserting property could not be found or created');
}
container.parent.id = parentContainer.id;
newContainer.parent.id = parentContainer.id;
}
// Ensure
this.ensureContainerNames(contentTypeUnique, type, parentId);
const frozenContainers =
this.#contentTypes.getValue().find((x) => x.unique === contentTypeUnique)?.containers ?? [];
const containers = appendToFrozenArray(frozenContainers, container, (x) => x.id === container.id);
const containers = appendToFrozenArray(frozenContainers, newContainer, (x) => x.id === newContainer.id);
this.#contentTypes.updateOne(contentTypeUnique, { containers } as Partial<T>);
}*/
return newContainer;
}
makeEmptyContainerName(
containerId: string,
@@ -537,7 +537,11 @@ export class UmbContentTypeStructureManager<
this.#contentTypes.updateOne(contentTypeUnique, { containers });
}
async removeContainer(contentTypeUnique: string | null, containerId: string | null = null) {
async removeContainer(
contentTypeUnique: string | null,
containerId: string | null = null,
args?: { preventRemovingProperties?: boolean },
): Promise<void> {
await this.#init;
contentTypeUnique = contentTypeUnique ?? this.#ownerContentTypeUnique!;
this.#editedTypes.appendOne(contentTypeUnique);
@@ -552,12 +556,15 @@ export class UmbContentTypeStructureManager<
.map((x) => x.id);
const containers = frozenContainers.filter((x) => x.id !== containerId && x.parent?.id !== containerId);
const frozenProperties = contentType.properties;
const properties = frozenProperties.filter((x) =>
const updates: Partial<T> = { containers } as Partial<T>;
if (args?.preventRemovingProperties !== true) {
updates.properties = contentType.properties.filter((x) =>
x.container ? !removedContainerIds.some((ids) => ids === x.container?.id) : true,
);
}
this.#contentTypes.updateOne(contentTypeUnique, { containers, properties } as Partial<T>);
this.#contentTypes.updateOne(contentTypeUnique, updates);
}
async insertProperty(contentTypeUnique: string | null, property: UmbPropertyTypeModel) {
@@ -655,6 +662,11 @@ export class UmbContentTypeStructureManager<
return undefined;
}
async getOwnerPropertyById(propertyUnique: string | null): Promise<UmbPropertyTypeModel | undefined> {
await this.#init;
return this.getOwnerContentType()?.properties?.find((property) => property.unique === propertyUnique);
}
async getPropertyStructureByAlias(propertyAlias: string) {
await this.#init;
for (const docType of this.#contentTypes.getValue()) {
@@ -695,17 +707,19 @@ export class UmbContentTypeStructureManager<
}
rootContainers(containerType: UmbPropertyContainerTypes) {
return this.#containers.asObservablePart((data) => {
return createObservablePart(this.#contentTypeContainers, (data) => {
return data.filter((x) => x.parent === null && x.type === containerType);
});
}
getRootContainers(containerType: UmbPropertyContainerTypes) {
return this.#containers.getValue().filter((x) => x.parent === null && x.type === containerType);
async getRootContainers(containerType: UmbPropertyContainerTypes) {
return (await firstValueFrom(this.#contentTypeContainers)).filter(
(x) => x.parent === null && x.type === containerType,
);
}
async hasRootContainers(containerType: UmbPropertyContainerTypes) {
return this.#containers.asObservablePart((data) => {
return createObservablePart(this.#contentTypeContainers, (data) => {
return data.filter((x) => x.parent === null && x.type === containerType).length > 0;
});
}
@@ -719,7 +733,14 @@ export class UmbContentTypeStructureManager<
);
}
getOwnerContainers(containerType: UmbPropertyContainerTypes, parentId: string | null) {
getOwnerContainerById(id: string | null): UmbPropertyTypeContainerModel | undefined {
return this.getOwnerContentType()?.containers?.find((x) => x.id === id);
}
getOwnerContainers(
containerType: UmbPropertyContainerTypes,
parentId: string | null,
): Array<UmbPropertyTypeContainerModel> | undefined {
return this.getOwnerContentType()?.containers?.filter(
(x) => (parentId ? x.parent?.id === parentId : x.parent === null) && x.type === containerType,
);
@@ -730,14 +751,14 @@ export class UmbContentTypeStructureManager<
}
containersOfParentId(parentId: string, containerType: UmbPropertyContainerTypes) {
return this.#containers.asObservablePart((data) => {
return createObservablePart(this.#contentTypeContainers, (data) => {
return data.filter((x) => x.parent?.id === parentId && x.type === containerType);
});
}
// In future this might need to take parentName(parentId lookup) into account as well? otherwise containers that share same name and type will always be merged, but their position might be different and they should not be merged. [NL]
containersByNameAndType(name: string, containerType: UmbPropertyContainerTypes) {
return this.#containers.asObservablePart((data) => {
return createObservablePart(this.#contentTypeContainers, (data) => {
return data.filter((x) => x.name === name && x.type === containerType);
});
}
@@ -748,7 +769,7 @@ export class UmbContentTypeStructureManager<
parentName: string | null,
parentType?: UmbPropertyContainerTypes,
) {
return this.#containers.asObservablePart((data) => {
return createObservablePart(this.#contentTypeContainers, (data) => {
return data.filter(
(x) =>
// Match name and type:
@@ -795,17 +816,27 @@ export class UmbContentTypeStructureManager<
);
}
/**
* Get all property aliases for the content type including inherited and composed content types.
* @returns {Promise<Array<string>>} - A promise that will be resolved with the list of all content type property aliases.
*/
async getContentTypePropertyAliases() {
return this.#contentTypes
.getValue()
.flatMap((x) => x.properties?.map((y) => y.alias) ?? [])
.filter(UmbFilterDuplicateStrings);
}
#clear() {
this.#contentTypeObservers.forEach((observer) => observer.destroy());
this.#contentTypeObservers = [];
this.#containers.setValue([]);
this.#repoManager?.clear();
this.#contentTypes.setValue([]);
this.#ownerContentTypeUnique = undefined;
}
public override destroy() {
this.#contentTypes.destroy();
this.#containers.destroy();
super.destroy();
}
}

View File

@@ -82,7 +82,7 @@ export abstract class UmbContentTypeWorkspaceContextBase<
): Promise<DetailModelType | undefined> {
this.resetState();
this.loading.addState({ unique: LOADING_STATE_UNIQUE, message: `Creating ${this.getEntityType()} scaffold` });
this.setParent(args.parent);
this._internal_setCreateUnderParent(args.parent);
const request = this.structure.createScaffold(args.preset);
this._getDataPromise = request;

View File

@@ -97,6 +97,35 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement {
i++;
}
},
onRequestDrop: async ({ unique }) => {
const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT);
if (!context) {
throw new Error('Could not get Workspace Context');
}
return context.structure.getOwnerPropertyById(unique);
},
requestExternalRemove: async ({ item }) => {
const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT);
if (!context) {
throw new Error('Could not get Workspace Context');
}
return await context.structure.removeProperty(null, item.unique).then(
() => true,
() => false,
);
},
requestExternalInsert: async ({ item }) => {
const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT);
if (!context) {
throw new Error('Could not get Workspace Context');
}
const parent = this._containerId ? { id: this._containerId } : null;
const updatedItem = { ...item, parent };
return await context.structure.insertProperty(null, updatedItem).then(
() => true,
() => false,
);
},
});
private _containerId: string | null | undefined;
@@ -152,7 +181,7 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement {
constructor() {
super();
this.#sorter.disable();
//this.#sorter.disable();
this.consumeContext(UMB_CONTENT_TYPE_DESIGN_EDITOR_CONTEXT, (context) => {
this.observe(
@@ -160,9 +189,9 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement {
(isSorting) => {
this._sortModeActive = isSorting;
if (isSorting) {
this.#sorter.enable();
//this.#sorter.enable();
} else {
this.#sorter.disable();
//this.#sorter.disable();
}
},
'_observeIsSorting',
@@ -305,6 +334,16 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement {
static override styles = [
UmbTextStyles,
css`
:host {
display: block;
}
#property-list {
/* enables dropping things into this despite it begin empty. */
margin-top: -20px;
padding-top: 20px;
}
#btn-add {
width: 100%;
--uui-button-height: var(--uui-size-14);

View File

@@ -72,6 +72,35 @@ export class UmbContentTypeDesignEditorTabElement extends UmbLitElement {
i++;
}
},
onRequestDrop: async ({ unique }) => {
const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT);
if (!context) {
throw new Error('Could not get Workspace Context');
}
return context.structure.getOwnerContainerById(unique);
},
requestExternalRemove: async ({ item }) => {
const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT);
if (!context) {
throw new Error('Could not get Workspace Context');
}
return await context.structure.removeContainer(null, item.id, { preventRemovingProperties: true }).then(
() => true,
() => false,
);
},
requestExternalInsert: async ({ item }) => {
const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT);
if (!context) {
throw new Error('Could not get Workspace Context');
}
const parent = this.#containerId ? { id: this.#containerId } : null;
const updatedItem = { ...item, parent };
return await context.structure.insertContainer(null, updatedItem).then(
() => true,
() => false,
);
},
});
#workspaceModal?: UmbModalRouteRegistrationController<
@@ -231,9 +260,10 @@ export class UmbContentTypeDesignEditorTabElement extends UmbLitElement {
.container-list {
display: grid;
gap: 10px;
align-content: start;
}
#convert-to-tab {
.container-list #convert-to-tab {
margin-bottom: var(--uui-size-layout-1);
display: flex;
}

View File

@@ -389,17 +389,25 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements
const currentOwnerCompositionCompositions = currentOwnerCompositions.filter(
(composition) => composition.compositionType === CompositionTypeModel.COMPOSITION,
);
const currentOwnerCompositionCompositionUniques = currentOwnerCompositionCompositions.map(
(composition) => composition.contentType.unique,
);
const currentOwnerInheritanceCompositions = currentOwnerCompositions.filter(
(composition) => composition.compositionType === CompositionTypeModel.INHERITANCE,
);
const currentPropertyAliases = await this.#workspaceContext.structure.getContentTypePropertyAliases();
const compositionConfiguration = {
compositionRepositoryAlias: this._compositionRepositoryAlias,
unique: unique,
selection: currentOwnerCompositionCompositions.map((composition) => composition.contentType.unique),
selection: currentOwnerCompositionCompositionUniques,
usedForInheritance: currentInheritanceCompositions.map((composition) => composition.contentType.unique),
usedForComposition: currentOwnerCompositionCompositionUniques,
isElement: ownerContentType.isElement,
currentPropertyAliases: [],
currentPropertyAliases,
isNew: this.#workspaceContext.getIsNew()!,
};
@@ -423,6 +431,12 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements
]);
}
#onDragOver(event: DragEvent, path: string) {
if (this._activePath === path) return;
event.preventDefault();
window.history.replaceState(null, '', path);
}
override render() {
return html`
<umb-body-layout header-fit-height>
@@ -499,8 +513,8 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements
}
renderRootTab() {
const rootTabPath = this._routerPath + '/root';
const rootTabActive = rootTabPath === this._activePath;
const path = this._routerPath + '/root';
const rootTabActive = path === this._activePath;
if (!this._hasRootGroups && !this._sortModeActive) {
// If we don't have any root groups and we are not in sort mode, then we don't want to render the root tab.
return nothing;
@@ -512,7 +526,8 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements
class=${this._hasRootGroups || rootTabActive ? '' : 'content-tab-is-empty'}
label=${this.localize.term('general_generic')}
.active=${rootTabActive}
href=${rootTabPath}>
href=${path}
@dragover=${(event: DragEvent) => this.#onDragOver(event, path)}>
${this.localize.term('general_generic')}
</uui-tab>
`;
@@ -529,7 +544,8 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements
href=${path}
data-umb-tab-id=${ifDefined(tab.id)}
data-mark="tab:${tab.name}"
?sortable=${ownedTab}>
?sortable=${ownedTab}
@dragover=${(event: DragEvent) => this.#onDragOver(event, path)}>
${this.renderTabInner(tab, tabActive, ownedTab)}
</uui-tab>`;
}

View File

@@ -12,7 +12,7 @@ import {
createObservablePart,
mergeObservables,
} from '@umbraco-cms/backoffice/observable-api';
import { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import { UmbVariantContext, UmbVariantId } from '@umbraco-cms/backoffice/variant';
import type { UmbContentTypeModel, UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type';
import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
@@ -47,6 +47,8 @@ export abstract class UmbElementPropertyDatasetContext<
protected _readOnly = new UmbBooleanState(false);
public readOnly = this._readOnly.asObservable();
#variantContext = new UmbVariantContext(this).inherit();
getEntityType(): string {
return this._dataOwner.getEntityType();
}
@@ -64,6 +66,7 @@ export abstract class UmbElementPropertyDatasetContext<
super(host, UMB_PROPERTY_DATASET_CONTEXT);
this._dataOwner = dataOwner;
this.#variantId = variantId ?? UmbVariantId.CreateInvariant();
this.#variantContext.setVariantId(this.#variantId);
this.#propertyVariantIdPromise = new Promise((resolve) => {
this.#propertyVariantIdPromiseResolver = resolve as any;

View File

@@ -714,6 +714,10 @@ export abstract class UmbContentDetailWorkspaceContextBase<
*/
public async runMandatoryValidationForSaveData(saveData: DetailModelType, variantIds: Array<UmbVariantId> = []) {
// Check that the data is valid before we save it.
// If we vary by culture then we do not want to validate the invariant variant.
if (this.getVariesByCulture()) {
variantIds = variantIds.filter((variant) => !variant.isCultureInvariant());
}
const missingVariants = variantIds.filter((variant) => {
return !saveData.variants.some((y) => variant.compare(y));
});
@@ -754,7 +758,7 @@ export abstract class UmbContentDetailWorkspaceContextBase<
// We ask the server first to get a concatenated set of validation messages. So we see both front-end and back-end validation messages [NL]
if (this.getIsNew()) {
const parent = this.getParent();
const parent = this._internal_getCreateUnderParent();
if (!parent) throw new Error('Parent is not set');
await this.#serverValidation.askServerForValidation(
saveData,
@@ -885,7 +889,7 @@ export abstract class UmbContentDetailWorkspaceContextBase<
async #create(variantIds: Array<UmbVariantId>, saveData: DetailModelType) {
if (!this._detailRepository) throw new Error('Detail repository is not set');
const parent = this.getParent();
const parent = this._internal_getCreateUnderParent();
if (!parent) throw new Error('Parent is not set');
const { data, error } = await this._detailRepository.create(saveData, parent.unique);

View File

@@ -29,7 +29,7 @@ export class UmbContentWorkspaceViewEditPropertiesElement extends UmbLitElement
}
@state()
_variantId?: UmbVariantId;
_datasetVariantId?: UmbVariantId;
@state()
_visibleProperties?: Array<UmbPropertyTypeModel>;
@@ -38,7 +38,7 @@ export class UmbContentWorkspaceViewEditPropertiesElement extends UmbLitElement
super();
this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (datasetContext) => {
this._variantId = datasetContext?.getVariantId();
this._datasetVariantId = datasetContext?.getVariantId();
this.#processPropertyStructure();
});
@@ -61,16 +61,19 @@ export class UmbContentWorkspaceViewEditPropertiesElement extends UmbLitElement
}
#processPropertyStructure() {
if (!this.#workspaceContext || !this.#properties || !this.#propertyStructureHelper) {
if (!this.#workspaceContext || !this.#properties || !this.#propertyStructureHelper || !this._datasetVariantId) {
return;
}
const propertyViewGuard = this.#workspaceContext.propertyViewGuard;
this.#properties.forEach((property) => {
const propertyVariantId = new UmbVariantId(this._variantId?.culture, this._variantId?.segment);
const propertyVariantId = new UmbVariantId(
property.variesByCulture ? this._datasetVariantId?.culture : null,
property.variesBySegment ? this._datasetVariantId?.segment : null,
);
this.observe(
propertyViewGuard.isPermittedForVariantAndProperty(propertyVariantId, property),
propertyViewGuard.isPermittedForVariantAndProperty(propertyVariantId, property, this._datasetVariantId!),
(permitted) => {
if (permitted) {
this.#visiblePropertiesUniques.push(property.unique);
@@ -95,14 +98,14 @@ export class UmbContentWorkspaceViewEditPropertiesElement extends UmbLitElement
}
override render() {
return this._variantId && this._visibleProperties
return this._datasetVariantId && this._visibleProperties
? repeat(
this._visibleProperties,
(property) => property.alias,
(property) =>
html`<umb-content-workspace-view-edit-property
class="property"
.variantId=${this._variantId}
.variantId=${this._datasetVariantId}
.property=${property}></umb-content-workspace-view-edit-property>`,
)
: nothing;

View File

@@ -46,7 +46,11 @@ export class UmbContentWorkspaceViewEditPropertyElement extends UmbLitElement {
})}].value`;
this.observe(
this._context.propertyWriteGuard.isPermittedForVariantAndProperty(propertyVariantId, this.property),
this._context.propertyWriteGuard.isPermittedForVariantAndProperty(
propertyVariantId,
this.property,
this.variantId,
),
(write) => {
this._writeable = write;
},

View File

@@ -6700,6 +6700,7 @@ export type GetItemDocumentSearchData = {
query?: {
query?: string;
trashed?: boolean;
culture?: string;
skip?: number;
take?: number;
parentId?: string;
@@ -8991,6 +8992,7 @@ export type GetItemMediaSearchData = {
query?: {
query?: string;
trashed?: boolean;
culture?: string;
skip?: number;
take?: number;
parentId?: string;

View File

@@ -25,7 +25,7 @@ import {
} from '@umbraco-cms/backoffice/entity-action';
import type { UmbActionEventContext } from '@umbraco-cms/backoffice/action';
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity';
import { UMB_ENTITY_CONTEXT, UmbParentEntityContext, type UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
import { UmbModalRouteRegistrationController, type UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router';
@@ -83,6 +83,7 @@ export class UmbDefaultCollectionContext<
});
#actionEventContext: UmbActionEventContext | undefined;
#parentEntityContext = new UmbParentEntityContext(this);
constructor(host: UmbControllerHost, defaultViewAlias: string, defaultFilter: Partial<FilterModelType> = {}) {
super(host, UMB_COLLECTION_CONTEXT);
@@ -92,6 +93,23 @@ export class UmbDefaultCollectionContext<
this.pagination.addEventListener(UmbChangeEvent.TYPE, this.#onPageChange);
this.#listenToEntityEvents();
// The parent entity context is used to get the parent entity for the collection items
// All items in the collection are children of the current entity context
this.consumeContext(UMB_ENTITY_CONTEXT, (context) => {
const currentEntityUnique = context?.getUnique();
const currentEntityType = context?.getEntityType();
const parent: UmbEntityModel | undefined =
currentEntityUnique && currentEntityType
? {
unique: currentEntityUnique,
entityType: currentEntityType,
}
: undefined;
this.#parentEntityContext?.setParent(parent);
});
}
setupView(viewElement: UmbControllerHost) {
@@ -225,6 +243,10 @@ export class UmbDefaultCollectionContext<
return this._manifest;
}
public getEmptyLabel(): string {
return this.manifest?.meta.noItemsLabel ?? this.#config?.noItemsLabel ?? '#collection_noItemsTitle';
}
/**
* Requests the collection from the repository.
* @returns {*}
@@ -261,6 +283,10 @@ export class UmbDefaultCollectionContext<
this.requestCollection();
}
public updateFilter(filter: Partial<FilterModelType>) {
this._filter.setValue({ ...this._filter.getValue(), ...filter });
}
public getLastSelectedView(unique: string | undefined): string | undefined {
if (!unique) return;

View File

@@ -23,6 +23,9 @@ umbExtensionsRegistry.register(manifest);
@customElement('umb-collection-default')
export class UmbCollectionDefaultElement extends UmbLitElement {
//
#collectionContext?: UmbDefaultCollectionContext;
@state()
private _routes: Array<UmbRoute> = [];
@@ -32,7 +35,8 @@ export class UmbCollectionDefaultElement extends UmbLitElement {
@state()
private _isDoneLoading = false;
#collectionContext?: UmbDefaultCollectionContext<any, any>;
@state()
private _emptyLabel?: string;
constructor() {
super();
@@ -40,6 +44,7 @@ export class UmbCollectionDefaultElement extends UmbLitElement {
this.#collectionContext = context;
this.#observeCollectionRoutes();
this.#observeTotalItems();
this.#getEmptyStateLabel();
await this.#collectionContext?.requestCollection();
this._isDoneLoading = true;
});
@@ -69,6 +74,10 @@ export class UmbCollectionDefaultElement extends UmbLitElement {
);
}
#getEmptyStateLabel() {
this._emptyLabel = this.#collectionContext?.getEmptyLabel();
}
override render() {
return this._routes
? html`
@@ -98,9 +107,10 @@ export class UmbCollectionDefaultElement extends UmbLitElement {
#renderEmptyState() {
if (!this._isDoneLoading) return nothing;
return html`
<div id="empty-state" class="uui-text">
<h4><umb-localize key="collection_noItemsTitle"></umb-localize></h4>
<h4>${this.localize.string(this._emptyLabel)}</h4>
</div>
`;
}

View File

@@ -9,6 +9,7 @@ export interface ManifestCollection
export interface MetaCollection {
repositoryAlias: string;
noItemsLabel?: string;
}
declare global {

View File

@@ -25,6 +25,7 @@ export interface UmbCollectionConfiguration {
orderBy?: string;
orderDirection?: string;
pageSize?: number;
noItemsLabel?: string;
userDefinedProperties?: Array<UmbCollectionColumnConfiguration>;
}

View File

@@ -12,13 +12,11 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
// TODO: maybe move this to UI Library.
@customElement('umb-dropdown')
export class UmbDropdownElement extends UmbLitElement {
@query('#dropdown-popover')
popoverContainerElement?: UUIPopoverContainerElement;
@property({ type: Boolean, reflect: true })
open = false;
@property()
label = '';
label?: string;
@property()
look: UUIInterfaceLook = 'default';
@@ -35,19 +33,16 @@ export class UmbDropdownElement extends UmbLitElement {
@property({ type: Boolean, attribute: 'hide-expand' })
hideExpand = false;
@query('#dropdown-popover')
popoverContainerElement?: UUIPopoverContainerElement;
protected override updated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
super.updated(_changedProperties);
if (_changedProperties.has('open') && this.popoverContainerElement) {
if (this.open) {
// TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS jet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.popoverContainerElement.showPopover();
this.openDropdown();
} else {
// TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS jet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.popoverContainerElement.hidePopover();
this.closeDropdown();
}
}
}
@@ -59,14 +54,29 @@ export class UmbDropdownElement extends UmbLitElement {
this.open = event.newState === 'open';
}
openDropdown() {
// TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS jet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.popoverContainerElement?.showPopover();
}
closeDropdown() {
// TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS jet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.popoverContainerElement?.hidePopover();
}
override render() {
return html`
<uui-button
id="dropdown-button"
popovertarget="dropdown-popover"
data-mark="open-dropdown"
.look=${this.look}
.color=${this.color}
.label=${this.label}
.label=${this.label ?? ''}
.compact=${this.compact}>
<slot name="label"></slot>
${when(

View File

@@ -1,7 +1,17 @@
import { UmbEntityContext } from '../../entity/entity.context.js';
import type { UmbDropdownElement } from '../dropdown/index.js';
import type { UmbEntityAction, ManifestEntityActionDefaultKind } from '@umbraco-cms/backoffice/entity-action';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
import { html, nothing, customElement, property, state, ifDefined, css } from '@umbraco-cms/backoffice/external/lit';
import {
html,
nothing,
customElement,
property,
state,
ifDefined,
css,
query,
} from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbExtensionsManifestInitializer, createExtensionApi } from '@umbraco-cms/backoffice/extension-api';
@@ -29,8 +39,8 @@ export class UmbEntityActionsBundleElement extends UmbLitElement {
@state()
private _firstActionHref?: string;
@state()
_dropdownIsOpen = false;
@query('#action-modal')
private _dropdownElement?: UmbDropdownElement;
// TODO: provide the entity context on a higher level, like the root element of this entity, tree-item/workspace/... [NL]
#entityContext = new UmbEntityContext(this);
@@ -79,7 +89,7 @@ export class UmbEntityActionsBundleElement extends UmbLitElement {
}
#onActionExecuted() {
this._dropdownIsOpen = false;
this._dropdownElement?.closeDropdown();
}
#onDropdownClick(event: Event) {
@@ -95,25 +105,27 @@ export class UmbEntityActionsBundleElement extends UmbLitElement {
if (this._numberOfActions === 1) return nothing;
return html`
<umb-dropdown id="action-modal" .open=${this._dropdownIsOpen} @click=${this.#onDropdownClick} compact hide-expand>
<uui-symbol-more slot="label" label="Open actions menu"></uui-symbol-more>
<umb-dropdown id="action-modal" @click=${this.#onDropdownClick} .label=${this.label} compact hide-expand>
<uui-symbol-more slot="label" .label=${this.label}></uui-symbol-more>
<uui-scroll-container>
<umb-entity-action-list
@action-executed=${this.#onActionExecuted}
.entityType=${this.entityType}
.unique=${this.unique}></umb-entity-action-list>
.unique=${this.unique}
.label=${this.label}></umb-entity-action-list>
</uui-scroll-container>
</umb-dropdown>
`;
}
#renderFirstAction() {
if (!this._firstActionApi) return nothing;
if (!this._firstActionApi || !this._firstActionManifest) return nothing;
return html`<uui-button
label=${ifDefined(this._firstActionManifest?.meta.label)}
label=${this.localize.string(this._firstActionManifest.meta.label)}
data-mark=${'entity-action:' + this._firstActionManifest.alias}
@click=${this.#onFirstActionClick}
href="${ifDefined(this._firstActionHref)}">
<uui-icon name=${ifDefined(this._firstActionManifest?.meta.icon)}></uui-icon>
<uui-icon name=${ifDefined(this._firstActionManifest.meta.icon)}></uui-icon>
</uui-button>`;
}

View File

@@ -66,17 +66,18 @@ export class UmbEntityActionDefaultElement<
}
override render() {
const label = this.manifest?.meta.label ? this.localize.string(this.manifest.meta.label) : this.manifest?.name;
if (!this.manifest) return nothing;
const label = this.manifest.meta.label ? this.localize.string(this.manifest.meta.label) : this.manifest.name;
return html`
<uui-menu-item
data-mark=${'entity-action:' + this.manifest?.alias}
label=${ifDefined(this.manifest?.meta.additionalOptions ? label + '…' : label)}
href=${ifDefined(this._href)}
@click-label=${this.#onClickLabel}
@click=${this.#onClick}>
${this.manifest?.meta.icon
? html`<umb-icon slot="icon" name="${this.manifest?.meta.icon}"></umb-icon>`
: nothing}
${this.manifest.meta.icon ? html`<umb-icon slot="icon" name="${this.manifest.meta.icon}"></umb-icon>` : nothing}
</uui-menu-item>
`;
}

View File

@@ -1,17 +1,20 @@
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import type { UmbEntityModel, UmbNamedEntityModel } from '@umbraco-cms/backoffice/entity';
import { html, nothing, customElement, property } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
@customElement('umb-entity-actions-table-column-view')
export class UmbEntityActionsTableColumnViewElement extends UmbLitElement {
@property({ attribute: false })
value?: UmbEntityModel;
value?: UmbEntityModel | UmbNamedEntityModel;
override render() {
if (!this.value) return nothing;
return html`
<umb-entity-actions-bundle .entityType=${this.value.entityType} .unique=${this.value.unique}>
<umb-entity-actions-bundle
.entityType=${this.value.entityType}
.unique=${this.value.unique}
.label=${this.localize.term('actions_viewActionsFor', [(this.value as any).name])}>
</umb-entity-actions-bundle>
`;
}

View File

@@ -8,6 +8,7 @@ import { UmbRoutePathAddendumContext } from '@umbraco-cms/backoffice/router';
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import './default-item-ref.element.js';
import { UmbDeselectedEvent, UmbSelectedEvent } from '@umbraco-cms/backoffice/event';
@customElement('umb-entity-item-ref')
export class UmbEntityItemRefElement extends UmbLitElement {
@@ -41,7 +42,7 @@ export class UmbEntityItemRefElement extends UmbLitElement {
}
#readonly = false;
@property({ type: Boolean, attribute: 'readonly' })
@property({ type: Boolean, reflect: true })
public get readonly() {
return this.#readonly;
}
@@ -54,7 +55,7 @@ export class UmbEntityItemRefElement extends UmbLitElement {
}
#standalone = false;
@property({ type: Boolean, attribute: 'standalone' })
@property({ type: Boolean, reflect: true })
public get standalone() {
return this.#standalone;
}
@@ -66,8 +67,74 @@ export class UmbEntityItemRefElement extends UmbLitElement {
}
}
#selectOnly = false;
@property({ type: Boolean, attribute: 'select-only', reflect: true })
public get selectOnly() {
return this.#selectOnly;
}
public set selectOnly(value) {
this.#selectOnly = value;
if (this._component) {
this._component.selectOnly = this.#selectOnly;
}
}
#selectable = false;
@property({ type: Boolean, reflect: true })
public get selectable() {
return this.#selectable;
}
public set selectable(value) {
this.#selectable = value;
if (this._component) {
this._component.selectable = this.#selectable;
}
}
#selected = false;
@property({ type: Boolean, reflect: true })
public get selected() {
return this.#selected;
}
public set selected(value) {
this.#selected = value;
if (this._component) {
this._component.selected = this.#selected;
}
}
#disabled = false;
@property({ type: Boolean, reflect: true })
public get disabled() {
return this.#disabled;
}
public set disabled(value) {
this.#disabled = value;
if (this._component) {
this._component.disabled = this.#disabled;
}
}
#pathAddendum = new UmbRoutePathAddendumContext(this);
#onSelected(event: UmbSelectedEvent) {
event.stopPropagation();
const unique = this.#item?.unique;
if (!unique) throw new Error('No unique id found for item');
this.dispatchEvent(new UmbSelectedEvent(unique));
}
#onDeselected(event: UmbDeselectedEvent) {
event.stopPropagation();
const unique = this.#item?.unique;
if (!unique) throw new Error('No unique id found for item');
this.dispatchEvent(new UmbDeselectedEvent(unique));
}
protected override firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
super.firstUpdated(_changedProperties);
this.setAttribute(UMB_MARK_ATTRIBUTE_NAME, 'entity-item-ref');
@@ -91,6 +158,13 @@ export class UmbEntityItemRefElement extends UmbLitElement {
component.item = this.#item;
component.readonly = this.readonly;
component.standalone = this.standalone;
component.selectOnly = this.selectOnly;
component.selectable = this.selectable;
component.selected = this.selected;
component.disabled = this.disabled;
component.addEventListener(UmbSelectedEvent.TYPE, this.#onSelected.bind(this));
component.addEventListener(UmbDeselectedEvent.TYPE, this.#onDeselected.bind(this));
// Proxy the actions slot to the component
const slotElement = document.createElement('slot');
@@ -110,6 +184,12 @@ export class UmbEntityItemRefElement extends UmbLitElement {
return html`${this._component}`;
}
override destroy(): void {
this._component?.removeEventListener(UmbSelectedEvent.TYPE, this.#onSelected.bind(this));
this._component?.removeEventListener(UmbDeselectedEvent.TYPE, this.#onDeselected.bind(this));
super.destroy();
}
static override styles = [
css`
:host {

View File

@@ -1 +1,2 @@
export * from './item-data-api-get-request-controller/index.js';
export * from './entity-item-ref/index.js';

View File

@@ -0,0 +1 @@
export * from './item-data-api-get-request.controller.js';

View File

@@ -0,0 +1,66 @@
import type { UmbItemDataApiGetRequestControllerArgs } from './types.js';
import {
batchTryExecute,
tryExecute,
UmbError,
type UmbApiError,
type UmbCancelError,
type UmbDataApiResponse,
} from '@umbraco-cms/backoffice/resources';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { batchArray } from '@umbraco-cms/backoffice/utils';
import { umbPeekError } from '@umbraco-cms/backoffice/notification';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
export class UmbItemDataApiGetRequestController<
ResponseModelType extends UmbDataApiResponse,
> extends UmbControllerBase {
#apiCallback: (args: { uniques: Array<string> }) => Promise<ResponseModelType>;
#uniques: Array<string>;
#batchSize: number = 40;
constructor(host: UmbControllerHost, args: UmbItemDataApiGetRequestControllerArgs<ResponseModelType>) {
super(host);
this.#apiCallback = args.api;
this.#uniques = args.uniques;
}
async request() {
if (!this.#uniques) throw new Error('Uniques are missing');
let data: ResponseModelType['data'] | undefined;
let error: UmbError | UmbApiError | UmbCancelError | Error | undefined;
if (this.#uniques.length > this.#batchSize) {
const chunks = batchArray<string>(this.#uniques, this.#batchSize);
const results = await batchTryExecute(this, chunks, (chunk) => this.#apiCallback({ uniques: chunk }));
const errors = results.filter((promiseResult) => promiseResult.status === 'rejected');
if (errors.length > 0) {
error = await this.#getAndHandleErrorResult(errors);
}
data = results
.filter((promiseResult) => promiseResult.status === 'fulfilled')
.flatMap((promiseResult) => promiseResult.value.data);
} else {
const result = await tryExecute(this, this.#apiCallback({ uniques: this.#uniques }));
data = result.data;
error = result.error;
}
return { data, error };
}
async #getAndHandleErrorResult(errors: Array<PromiseRejectedResult>) {
// TODO: We currently expect all the errors to be the same, but we should handle this better in the future.
const error = errors[0];
await umbPeekError(this, {
headline: 'Error fetching items',
message: 'An error occurred while fetching items.',
});
return new UmbError(error.reason);
}
}

View File

@@ -0,0 +1,6 @@
import type { UmbDataApiResponse } from '@umbraco-cms/backoffice/resources';
export interface UmbItemDataApiGetRequestControllerArgs<ResponseModelType extends UmbDataApiResponse> {
api: (args: { uniques: Array<string> }) => Promise<ResponseModelType>;
uniques: Array<string>;
}

View File

@@ -1,4 +1,5 @@
import type { UmbNamedEntityModel } from '@umbraco-cms/backoffice/entity';
export type * from './item-data-api-get-request-controller/types.js';
export interface UmbDefaultItemModel extends UmbNamedEntityModel {
icon?: string;

View File

@@ -1 +1,2 @@
export * from './contexts/ancestors/constants.js';
export * from './contexts/parent/constants.js';

View File

@@ -0,0 +1 @@
export { UMB_PARENT_ENTITY_CONTEXT } from './parent.entity-context-token.js';

View File

@@ -0,0 +1 @@
export { UmbParentEntityContext } from './parent.entity-context.js';

View File

@@ -0,0 +1,4 @@
import type { UmbParentEntityContext } from './parent.entity-context.js';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
export const UMB_PARENT_ENTITY_CONTEXT = new UmbContextToken<UmbParentEntityContext>('UmbParentEntityContext');

View File

@@ -0,0 +1,38 @@
import type { UmbEntityModel } from '../../types.js';
import { UMB_PARENT_ENTITY_CONTEXT } from './parent.entity-context-token.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
/**
* A entity context for the parent
* @class UmbParentEntityContext
* @augments {UmbContextBase}
* @implements {UmbParentEntityContext}
*/
export class UmbParentEntityContext extends UmbContextBase {
#parent = new UmbObjectState<UmbEntityModel | undefined>(undefined);
parent = this.#parent.asObservable();
constructor(host: UmbControllerHost) {
super(host, UMB_PARENT_ENTITY_CONTEXT);
}
/**
* Gets the parent state
* @returns {UmbEntityModel | undefined} - The parent state
* @memberof UmbParentEntityContext
*/
getParent(): UmbEntityModel | undefined {
return this.#parent.getValue();
}
/**
* Sets the parent state
* @param {UmbEntityModel | undefined} parent - The parent state
* @memberof UmbParentEntityContext
*/
setParent(parent: UmbEntityModel | undefined): void {
this.#parent.setValue(parent);
}
}

View File

@@ -2,4 +2,6 @@ export { UMB_ENTITY_CONTEXT } from './entity.context-token.js';
export { UmbEntityContext } from './entity.context.js';
export * from './constants.js';
export * from './contexts/ancestors/index.js';
export * from './contexts/parent/index.js';
export type * from './types.js';

View File

@@ -1,5 +1,6 @@
import { html, customElement, property, ifDefined, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { ensureSlash } from '@umbraco-cms/backoffice/router';
import { debounce } from '@umbraco-cms/backoffice/utils';
/**
@@ -62,8 +63,12 @@ export class UmbMenuItemLayoutElement extends UmbLitElement {
return;
}
const location = window.location.pathname;
this._isActive = location.includes(this.href);
/* Check if the current location includes the href of this menu item
We ensure that the paths ends with a slash to avoid collisions with paths like /path-1 and /path-1-2 where /path is in both.
Instead we compare /path-1/ with /path-1-2/ which wont collide.*/
const location = ensureSlash(window.location.pathname);
const compareHref = ensureSlash(this.href);
this._isActive = location.includes(compareHref);
}
override render() {
@@ -80,7 +85,7 @@ export class UmbMenuItemLayoutElement extends UmbLitElement {
slot="actions"
.entityType=${this.entityType}
.unique=${null}
.label=${this.label}>
.label=${this.localize.term('actions_viewActionsFor', [this.label])}>
</umb-entity-actions-bundle>`
: ''}
<slot></slot>

View File

@@ -1,5 +1,7 @@
export * from './components/index.js';
export * from './menu-tree-structure-workspace-context-base.js';
export * from './menu-structure-workspace-context.context-token.js';
export * from './menu-variant-structure-workspace-context.context-token.js';
export * from './menu-variant-tree-structure-workspace-context-base.js';
export type * from './types.js';

View File

@@ -0,0 +1,7 @@
import type { UmbMenuStructureWorkspaceContext } from './menu-structure-workspace-context.interface.js';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
export const UMB_MENU_STRUCTURE_WORKSPACE_CONTEXT = new UmbContextToken<UmbMenuStructureWorkspaceContext>(
'UmbWorkspaceContext',
'UmbMenuStructure',
);

View File

@@ -1,32 +1,41 @@
import type { UmbStructureItemModel } from './types.js';
import { UMB_MENU_STRUCTURE_WORKSPACE_CONTEXT } from './menu-structure-workspace-context.context-token.js';
import type { UmbTreeRepository, UmbTreeItemModel, UmbTreeRootModel } from '@umbraco-cms/backoffice/tree';
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UMB_SUBMITTABLE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import { UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import { UmbArrayState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbAncestorsEntityContext, UmbParentEntityContext, type UmbEntityModel } from '@umbraco-cms/backoffice/entity';
interface UmbMenuTreeStructureWorkspaceContextBaseArgs {
treeRepositoryAlias: string;
}
// TODO: introduce base class for all menu structure workspaces to handle ancestors and parent
export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContextBase {
#workspaceContext?: typeof UMB_SUBMITTABLE_WORKSPACE_CONTEXT.TYPE;
#workspaceContext?: typeof UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT.TYPE;
#args: UmbMenuTreeStructureWorkspaceContextBaseArgs;
#structure = new UmbArrayState<UmbStructureItemModel>([], (x) => x.unique);
public readonly structure = this.#structure.asObservable();
#parent = new UmbObjectState<UmbStructureItemModel | undefined>(undefined);
/**
* @deprecated Will be removed in v.18: Use UMB_PARENT_ENTITY_CONTEXT instead.
*/
public readonly parent = this.#parent.asObservable();
#parentContext = new UmbParentEntityContext(this);
#ancestorContext = new UmbAncestorsEntityContext(this);
constructor(host: UmbControllerHost, args: UmbMenuTreeStructureWorkspaceContextBaseArgs) {
// TODO: set up context token
super(host, 'UmbMenuStructureWorkspaceContext');
super(host, UMB_MENU_STRUCTURE_WORKSPACE_CONTEXT);
// 'UmbMenuStructureWorkspaceContext' is Obsolete, will be removed in v.18
this.provideContext('UmbMenuStructureWorkspaceContext', this);
this.#args = args;
// TODO: set up context token that supports parentEntityType, parentUnique, entityType.
this.consumeContext(UMB_SUBMITTABLE_WORKSPACE_CONTEXT, (instance) => {
this.consumeContext(UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT, (instance) => {
this.#workspaceContext = instance;
this.observe(this.#workspaceContext?.unique, (value) => {
if (!value) return;
@@ -59,14 +68,16 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex
const isNew = this.#workspaceContext?.getIsNew();
const entityTypeObservable = isNew
? (this.#workspaceContext as any)?.parentEntityType
: (this.#workspaceContext as any).entityType;
? this.#workspaceContext?._internal_createUnderParentEntityType
: this.#workspaceContext?.entityType;
const entityType = (await this.observe(entityTypeObservable, () => {})?.asPromise()) as string;
if (!entityType) throw new Error('Entity type is not available');
// If the entity type is different from the root entity type, then we can request the ancestors.
if (entityType !== root?.entityType) {
const uniqueObservable = isNew ? (this.#workspaceContext as any)?.parentUnique : this.#workspaceContext?.unique;
const uniqueObservable = isNew
? this.#workspaceContext?._internal_createUnderParentEntityUnique
: this.#workspaceContext?.unique;
const unique = (await this.observe(uniqueObservable, () => {})?.asPromise()) as string;
if (!unique) throw new Error('Unique is not available');
@@ -83,11 +94,49 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex
});
structureItems.push(...ancestorItems);
this.#structure.setValue(structureItems);
this.#setParentData(structureItems);
this.#setAncestorData(data);
}
}
}
const parent = structureItems[structureItems.length - 2];
#setParentData(structureItems: Array<UmbStructureItemModel>) {
/* If the item is not new, the current item is the last item in the array.
We filter out the current item unique to handle any case where it could show up */
const parent = structureItems.filter((item) => item.unique !== this.#workspaceContext?.getUnique()).pop();
// TODO: remove this when the parent gets removed from the structure interface
this.#parent.setValue(parent);
this.#structure.setValue(structureItems);
const parentEntity = parent
? {
unique: parent.unique,
entityType: parent.entityType,
}
: undefined;
this.#parentContext.setParent(parentEntity);
}
/* Notice: ancestors are based on the server "data" ancestors and are not based on the full Menu (UI) structure.
This will mean that any item placed in the data root will not have any ancestors. But will have a parent based on the UI structure.
*/
#setAncestorData(ancestors: Array<UmbTreeItemModel>) {
const ancestorEntities = ancestors
.map((treeItem) => {
const entity: UmbEntityModel = {
unique: treeItem.unique,
entityType: treeItem.entityType,
};
return entity;
})
/* If the item is not new, the current item is the last item in the array.
We filter out the current item unique to handle any case where it could show up */
.filter((item) => item.unique !== this.#workspaceContext?.getUnique());
this.#ancestorContext.setAncestors(ancestorEntities);
}
}

View File

@@ -0,0 +1,10 @@
import type { UmbMenuVariantStructureWorkspaceContext } from './menu-variant-structure-workspace-context.interface.js';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
export const UMB_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT =
new UmbContextToken<UmbMenuVariantStructureWorkspaceContext>(
'UmbWorkspaceContext',
'UmbMenuStructure',
(context): context is UmbMenuVariantStructureWorkspaceContext =>
'IS_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT' in context,
);

View File

@@ -0,0 +1,7 @@
import type { UmbVariantStructureItemModel } from './types.js';
import type { UmbContext } from '@umbraco-cms/backoffice/class-api';
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
export interface UmbMenuVariantStructureWorkspaceContext extends UmbContext {
structure: Observable<UmbVariantStructureItemModel[]>;
}

View File

@@ -1,36 +1,44 @@
import type { UmbVariantStructureItemModel } from './types.js';
import type { UmbTreeRepository, UmbTreeRootModel } from '@umbraco-cms/backoffice/tree';
import { UMB_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT } from './menu-variant-structure-workspace-context.context-token.js';
import type { UmbTreeItemModel, UmbTreeRepository, UmbTreeRootModel } from '@umbraco-cms/backoffice/tree';
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UMB_VARIANT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import { UmbArrayState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbAncestorsEntityContext } from '@umbraco-cms/backoffice/entity';
import { UmbAncestorsEntityContext, UmbParentEntityContext, type UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import { UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
interface UmbMenuVariantTreeStructureWorkspaceContextBaseArgs {
treeRepositoryAlias: string;
}
// TODO: introduce base class for all menu structure workspaces to handle ancestors and parent
export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends UmbContextBase {
// TODO: add correct interface
#workspaceContext?: typeof UMB_VARIANT_WORKSPACE_CONTEXT.TYPE;
//
#workspaceContext?: typeof UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT.TYPE;
#args: UmbMenuVariantTreeStructureWorkspaceContextBaseArgs;
#structure = new UmbArrayState<UmbVariantStructureItemModel>([], (x) => x.unique);
public readonly structure = this.#structure.asObservable();
#parent = new UmbObjectState<UmbVariantStructureItemModel | undefined>(undefined);
/**
* @deprecated Will be removed in v.18: Use UMB_PARENT_ENTITY_CONTEXT instead.
*/
public readonly parent = this.#parent.asObservable();
#parentContext = new UmbParentEntityContext(this);
#ancestorContext = new UmbAncestorsEntityContext(this);
public readonly IS_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT = true;
constructor(host: UmbControllerHost, args: UmbMenuVariantTreeStructureWorkspaceContextBaseArgs) {
// TODO: set up context token
super(host, 'UmbMenuStructureWorkspaceContext');
super(host, UMB_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT);
// 'UmbMenuStructureWorkspaceContext' is Obsolete, will be removed in v.18
this.provideContext('UmbMenuStructureWorkspaceContext', this);
this.#args = args;
// TODO: Implement a Context Token that supports parentUnique, parentEntityType, entityType
this.consumeContext(UMB_VARIANT_WORKSPACE_CONTEXT, (instance) => {
this.consumeContext(UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT, (instance) => {
this.#workspaceContext = instance;
this.observe(
this.#workspaceContext?.unique,
@@ -45,10 +53,12 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
async #requestStructure() {
const isNew = this.#workspaceContext?.getIsNew();
const uniqueObservable = isNew ? (this.#workspaceContext as any)?.parentUnique : this.#workspaceContext?.unique;
const uniqueObservable = isNew
? this.#workspaceContext?._internal_createUnderParentEntityType
: this.#workspaceContext?.unique;
const entityTypeObservable = isNew
? (this.#workspaceContext as any)?.parentEntityType
: (this.#workspaceContext as any)?.entityType;
? this.#workspaceContext?._internal_createUnderParentEntityUnique
: this.#workspaceContext?.entityType;
let structureItems: Array<UmbVariantStructureItemModel> = [];
@@ -58,7 +68,7 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
const entityType = (await this.observe(entityTypeObservable, () => {})?.asPromise()) as string;
if (!entityType) throw new Error('Entity type is not available');
// TODO: add correct tree variant item model
// TODO: introduce variant tree item model
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository<any, UmbTreeRootModel>>(
this,
this.#args.treeRepositoryAlias,
@@ -79,7 +89,7 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
const { data } = await treeRepository.requestTreeItemAncestors({ treeItem: { unique, entityType } });
if (data) {
const ancestorItems = data.map((treeItem) => {
const treeItemAncestors = data.map((treeItem) => {
return {
unique: treeItem.unique,
entityType: treeItem.entityType,
@@ -93,20 +103,49 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
};
});
const ancestorEntities = data.map((treeItem) => {
return {
structureItems.push(...treeItemAncestors);
this.#structure.setValue(structureItems);
this.#setParentData(structureItems);
this.#setAncestorData(data);
}
}
#setParentData(structureItems: Array<UmbVariantStructureItemModel>) {
/* If the item is not new, the current item is the last item in the array.
We filter out the current item unique to handle any case where it could show up */
const parent = structureItems.filter((item) => item.unique !== this.#workspaceContext?.getUnique()).pop();
// TODO: remove this when the parent gets removed from the structure interface
this.#parent.setValue(parent);
const parentEntity = parent
? {
unique: parent.unique,
entityType: parent.entityType,
}
: undefined;
this.#parentContext.setParent(parentEntity);
}
/* Notice: ancestors are based on the server "data" ancestors and are not based on the full Menu (UI) structure.
This will mean that any item placed in the data root will not have any ancestors. But will have a parent based on the UI structure.
*/
#setAncestorData(ancestors: Array<UmbTreeItemModel>) {
const ancestorEntities = ancestors
.map((treeItem) => {
const entity: UmbEntityModel = {
unique: treeItem.unique,
entityType: treeItem.entityType,
};
});
return entity;
})
/* If the item is not new, the current item is the last item in the array.
We filter out the current item unique to handle any case where it could show up */
.filter((item) => item.unique !== this.#workspaceContext?.getUnique());
this.#ancestorContext.setAncestors(ancestorEntities);
structureItems.push(...ancestorItems);
const parent = structureItems[structureItems.length - 2];
this.#parent.setValue(parent);
this.#structure.setValue(structureItems);
}
}
}

View File

@@ -1,8 +1,9 @@
import { UmbSectionSidebarMenuElement } from '../section-sidebar-menu/section-sidebar-menu.element.js';
import type { ManifestSectionSidebarAppMenuWithEntityActionsKind } from '../section-sidebar-menu/types.js';
import { css, html, customElement } from '@umbraco-cms/backoffice/external/lit';
import { css, html, customElement, type PropertyValues, state } from '@umbraco-cms/backoffice/external/lit';
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbParentEntityContext } from '@umbraco-cms/backoffice/entity';
const manifestWithEntityActions: UmbExtensionManifestKind = {
type: 'kind',
@@ -18,15 +19,30 @@ umbExtensionsRegistry.register(manifestWithEntityActions);
@customElement('umb-section-sidebar-menu-with-entity-actions')
export class UmbSectionSidebarMenuWithEntityActionsElement extends UmbSectionSidebarMenuElement<ManifestSectionSidebarAppMenuWithEntityActionsKind> {
@state()
_unique = null;
@state()
_entityType?: string | null;
#parentContext = new UmbParentEntityContext(this);
protected override updated(_changedProperties: PropertyValues<this>): void {
if (_changedProperties.has('manifest')) {
const entityType = this.manifest?.meta.entityType;
this.#parentContext.setParent(entityType ? { unique: this._unique, entityType } : undefined);
}
}
override renderHeader() {
return html`
<div id="header">
<h3>${this.localize.string(this.manifest?.meta?.label ?? '')}</h3>
<umb-entity-actions-bundle
slot="actions"
.unique=${null}
.unique=${this._unique}
.entityType=${this.manifest?.meta.entityType}
.label=${this.manifest?.meta.label}>
.label=${this.localize.term('actions_viewActionsFor', [this.manifest?.meta.label])}>
</umb-entity-actions-bundle>
</div>
`;

View File

@@ -68,7 +68,6 @@ export class UmbPickerSearchResultElement extends UmbLitElement {
}
#renderResultItem(item: UmbEntityModel) {
console.log('pickableFilter', this.pickableFilter(item));
return html`
<umb-extension-with-api-slot
type="pickerSearchResultItem"

View File

@@ -44,7 +44,12 @@ export class UmbPropertyActionMenuElement extends UmbLitElement {
override render() {
if (!this._actions?.length) return nothing;
return html`
<uui-button id="popover-trigger" popovertarget="property-action-popover" label="Open actions menu" compact>
<uui-button
id="popover-trigger"
popovertarget="property-action-popover"
data-mark="open-property-actions"
label=${this.localize.term('actions_viewActionsFor')}
compact>
<uui-symbol-more id="more-symbol"></uui-symbol-more>
</uui-button>
<uui-popover-container id="property-action-popover">

View File

@@ -5,7 +5,7 @@ import type { UmbNameablePropertyDatasetContext } from './nameable-property-data
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbArrayState, UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import { UmbVariantContext, UmbVariantId } from '@umbraco-cms/backoffice/variant';
/**
* A base property dataset context implementation.
@@ -32,26 +32,34 @@ export class UmbPropertyDatasetContextBase
#readOnly = new UmbBooleanState(false);
public readOnly = this.#readOnly.asObservable();
#variantId: UmbVariantId = UmbVariantId.CreateInvariant();
#variantContext = new UmbVariantContext(this).inherit();
getEntityType() {
return this._entityType;
}
getUnique() {
return this._unique;
}
getName() {
return this.#name.getValue();
}
setName(name: string | undefined) {
this.#name.setValue(name);
}
getVariantId() {
return UmbVariantId.CreateInvariant();
return this.#variantId;
}
// variant id for a specific property?
constructor(host: UmbControllerHost) {
// The controller alias, is a very generic name cause we want only one of these for this controller host.
super(host, UMB_PROPERTY_DATASET_CONTEXT);
this.#variantContext.setVariantId(this.getVariantId());
}
/**

View File

@@ -70,7 +70,7 @@ describe('UmbVariantPropertyGuardManager', () => {
it('is not permitted for a variant when no states', (done) => {
manager
.isPermittedForVariantAndProperty(invariantVariant, propB)
.isPermittedForVariantAndProperty(invariantVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -82,7 +82,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(ruleEn);
manager
.isPermittedForVariantAndProperty(englishVariant, propB)
.isPermittedForVariantAndProperty(englishVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.true;
done();
@@ -94,7 +94,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(ruleInv);
manager
.isPermittedForVariantAndProperty(englishVariant, propB)
.isPermittedForVariantAndProperty(englishVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -106,7 +106,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(statePropAInv);
manager
.isPermittedForVariantAndProperty(englishVariant, propB)
.isPermittedForVariantAndProperty(englishVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -117,7 +117,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(statePropAInv);
manager
.isPermittedForVariantAndProperty(invariantVariant, propB)
.isPermittedForVariantAndProperty(invariantVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -129,7 +129,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(statePropAInv);
manager
.isPermittedForVariantAndProperty(englishVariant, propA)
.isPermittedForVariantAndProperty(englishVariant, propA, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -141,7 +141,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(rulePlain);
manager
.isPermittedForVariantAndProperty(englishVariant, propB)
.isPermittedForVariantAndProperty(englishVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.true;
done();
@@ -154,7 +154,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(ruleNoEn);
manager
.isPermittedForVariantAndProperty(englishVariant, propB)
.isPermittedForVariantAndProperty(englishVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -166,7 +166,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(ruleNoPlain);
manager
.isPermittedForVariantAndProperty(englishVariant, propB)
.isPermittedForVariantAndProperty(englishVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -179,7 +179,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(ruleEn);
manager
.isPermittedForVariantAndProperty(englishVariant, propB)
.isPermittedForVariantAndProperty(englishVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -193,7 +193,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(ruleNoEn);
manager
.isPermittedForVariantAndProperty(englishVariant, propB)
.isPermittedForVariantAndProperty(englishVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -206,7 +206,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(rulePlain);
manager
.isPermittedForVariantAndProperty(englishVariant, propB)
.isPermittedForVariantAndProperty(englishVariant, propB, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -220,7 +220,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(statePropAInv);
manager
.isPermittedForVariantAndProperty(invariantVariant, propA)
.isPermittedForVariantAndProperty(invariantVariant, propA, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();
@@ -234,7 +234,7 @@ describe('UmbVariantPropertyGuardManager', () => {
manager.addRule(stateNoPropAInv);
manager
.isPermittedForVariantAndProperty(invariantVariant, propA)
.isPermittedForVariantAndProperty(invariantVariant, propA, invariantVariant)
.subscribe((value) => {
expect(value).to.be.false;
done();

View File

@@ -5,21 +5,39 @@ import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models';
import { UmbGuardManagerBase } from '@umbraco-cms/backoffice/utils';
export interface UmbVariantPropertyGuardRule extends UmbPropertyGuardRule {
/**
* @description - The variant id of the property.
* @type {UmbVariantId}
* @memberof UmbVariantPropertyGuardRule
*/
variantId?: UmbVariantId;
/**
* @description - The variant id of the dataset. This is used to determine if the rule applies to the current dataset.
* @type {UmbVariantId}
* @memberof UmbVariantPropertyGuardRule
*/
datasetVariantId?: UmbVariantId;
}
/**
*
* @param rule
* @param variantId
* @param propertyType
* @param {UmbVariantPropertyGuardRule} rule - The rule to check.
* @param {UmbVariantId} variantId - The property variant id to check.
* @param {UmbReferenceByUnique} propertyType - The property type to check.
* @param {UmbVariantId} datasetVariantId - The variant id of the dataset. This is used to determine if the rule applies to the current dataset.
* @returns {boolean} - Returns true if the rule applies to the given conditions.
*/
function findRule(rule: UmbVariantPropertyGuardRule, variantId: UmbVariantId, propertyType: UmbReferenceByUnique) {
function findRule(
rule: UmbVariantPropertyGuardRule,
variantId: UmbVariantId,
propertyType: UmbReferenceByUnique,
datasetVariantId: UmbVariantId,
) {
return (
(rule.variantId?.compare(variantId) && rule.propertyType?.unique === propertyType.unique) ||
(rule.variantId === undefined && rule.propertyType?.unique === propertyType.unique) ||
(rule.variantId?.compare(variantId) && rule.propertyType === undefined) ||
(rule.variantId === undefined && rule.propertyType === undefined)
(rule.variantId === undefined || rule.variantId.culture === variantId.culture) &&
(rule.propertyType === undefined || rule.propertyType.unique === propertyType.unique) &&
(rule.datasetVariantId === undefined || rule.datasetVariantId.culture === datasetVariantId.culture)
);
}
@@ -34,33 +52,54 @@ export class UmbVariantPropertyGuardManager extends UmbGuardManagerBase<UmbVaria
* Checks if the variant and propertyType is permitted.
* @param {UmbVariantId} variantId - The variant id to check.
* @param {UmbReferenceByUnique} propertyType - The property type to check.
* @param {UmbVariantId} datasetVariantId - The dataset variant id to check.
* @returns {Observable<boolean>} - Returns an observable that emits true if the variant and propertyType is permitted, false otherwise.
* @memberof UmbVariantPropertyGuardManager
*/
isPermittedForVariantAndProperty(variantId: UmbVariantId, propertyType: UmbReferenceByUnique): Observable<boolean> {
return this._rules.asObservablePart((rules) => this.#resolvePermission(rules, variantId, propertyType));
isPermittedForVariantAndProperty(
variantId: UmbVariantId,
propertyType: UmbReferenceByUnique,
datasetVariantId: UmbVariantId,
): Observable<boolean> {
return this._rules.asObservablePart((rules) =>
this.#resolvePermission(rules, variantId, propertyType, datasetVariantId),
);
}
/**
* Checks if the variant and propertyType is permitted.
* @param {UmbVariantId} variantId - The variant id to check.
* @param {UmbReferenceByUnique} propertyType - The property type to check.
* @param {UmbVariantId} datasetVariantId - The dataset variant id to check.
* @returns {boolean} - Returns true if the variant and propertyType is permitted, false otherwise.
* @memberof UmbVariantPropertyGuardManager
*/
getIsPermittedForVariantAndProperty(variantId: UmbVariantId, propertyType: UmbReferenceByUnique): boolean {
return this.#resolvePermission(this._rules.getValue(), variantId, propertyType);
getIsPermittedForVariantAndProperty(
variantId: UmbVariantId,
propertyType: UmbReferenceByUnique,
datasetVariantId: UmbVariantId,
): boolean {
return this.#resolvePermission(this._rules.getValue(), variantId, propertyType, datasetVariantId);
}
#resolvePermission(
rules: UmbVariantPropertyGuardRule[],
variantId: UmbVariantId,
propertyType: UmbReferenceByUnique,
datasetVariantId: UmbVariantId,
) {
if (
rules
.filter((x) => x.permitted === false)
.some((rule) => findRule(rule, variantId, propertyType, datasetVariantId))
) {
if (rules.filter((x) => x.permitted === false).some((rule) => findRule(rule, variantId, propertyType))) {
return false;
}
if (rules.filter((x) => x.permitted === true).some((rule) => findRule(rule, variantId, propertyType))) {
if (
rules
.filter((x) => x.permitted === true)
.some((rule) => findRule(rule, variantId, propertyType, datasetVariantId))
) {
return true;
}
return this._fallback;

View File

@@ -1,10 +1,11 @@
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbDataSourceResponse } from '../data-source-response.interface.js';
import type { UmbItemDataSource } from './item-data-source.interface.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { tryExecute } from '@umbraco-cms/backoffice/resources';
export interface UmbItemServerDataSourceBaseArgs<ServerItemType, ClientItemType extends { unique: string }> {
getItems: (uniques: Array<string>) => Promise<UmbDataSourceResponse<Array<ServerItemType>>>;
getItems?: (uniques: Array<string>) => Promise<UmbDataSourceResponse<Array<ServerItemType>>>;
mapper: (item: ServerItemType) => ClientItemType;
}
@@ -14,10 +15,10 @@ export interface UmbItemServerDataSourceBaseArgs<ServerItemType, ClientItemType
* @implements {DocumentTreeDataSource}
*/
export abstract class UmbItemServerDataSourceBase<ServerItemType, ClientItemType extends { unique: string }>
extends UmbControllerBase
implements UmbItemDataSource<ClientItemType>
{
#host: UmbControllerHost;
#getItems: (uniques: Array<string>) => Promise<UmbDataSourceResponse<Array<ServerItemType>>>;
#getItems?: (uniques: Array<string>) => Promise<UmbDataSourceResponse<Array<ServerItemType>>>;
#mapper: (item: ServerItemType) => ClientItemType;
/**
@@ -27,7 +28,7 @@ export abstract class UmbItemServerDataSourceBase<ServerItemType, ClientItemType
* @memberof UmbItemServerDataSourceBase
*/
constructor(host: UmbControllerHost, args: UmbItemServerDataSourceBaseArgs<ServerItemType, ClientItemType>) {
this.#host = host;
super(host);
this.#getItems = args.getItems;
this.#mapper = args.mapper;
}
@@ -39,14 +40,17 @@ export abstract class UmbItemServerDataSourceBase<ServerItemType, ClientItemType
* @memberof UmbItemServerDataSourceBase
*/
async getItems(uniques: Array<string>) {
if (!this.#getItems) throw new Error('getItems is not implemented');
if (!uniques) throw new Error('Uniques are missing');
const { data, error } = await tryExecute(this.#host, this.#getItems(uniques));
if (data) {
const items = data.map((item) => this.#mapper(item));
return { data: items };
const { data, error } = await tryExecute(this, this.#getItems(uniques));
return { data: this._getMappedItems(data), error };
}
return { error };
protected _getMappedItems(items: Array<ServerItemType> | undefined): Array<ClientItemType> | undefined {
if (!items) return undefined;
if (!this.#mapper) throw new Error('Mapper is not implemented');
return items.map((item) => this.#mapper(item));
}
}

View File

@@ -33,10 +33,10 @@ export class UmbRepositoryDetailsManager<DetailType extends { unique: string }>
return this.#init;
}
#uniques = new UmbArrayState<string>([], (x) => x);
#uniques = new UmbArrayState<DetailType['unique']>([], (x) => x);
uniques = this.#uniques.asObservable();
#entries = new UmbArrayState<DetailType>([], (x) => x.unique);
#entries = new UmbArrayState<DetailType, DetailType['unique']>([], (x) => x.unique);
entries = this.#entries.asObservable();
#statuses = new UmbArrayState<UmbRepositoryRequestStatus>([], (x) => x.unique);
@@ -77,11 +77,15 @@ export class UmbRepositoryDetailsManager<DetailType extends { unique: string }>
this.uniques,
(uniques) => {
// remove entries based on no-longer existing uniques:
const removedEntries = this.#entries.getValue().filter((entry) => !uniques.includes(entry.unique));
this.#entries.remove(removedEntries);
const removedEntries = this.#entries
.getValue()
.filter((entry) => !uniques.includes(entry.unique))
.map((x) => x.unique);
this.#statuses.remove(removedEntries);
this.#entries.remove(removedEntries);
removedEntries.forEach((entry) => {
this.removeUmbControllerByAlias('observeEntry_' + entry.unique);
this.removeUmbControllerByAlias('observeEntry_' + entry);
});
this.#requestNewDetails();

View File

@@ -16,6 +16,7 @@ export class UmbApiInterceptorController extends UmbControllerBase {
this.addAuthResponseInterceptor(client);
this.addUmbGeneratedResourceInterceptor(client);
this.addUmbNotificationsInterceptor(client);
this.addForbiddenResponseInterceptor(client);
this.addErrorInterceptor(client);
}
@@ -38,6 +39,24 @@ export class UmbApiInterceptorController extends UmbControllerBase {
});
}
/**
* Interceptor which checks responses for 403 errors and displays them as a notification.
* @param {umbHttpClient} client The OpenAPI client to add the interceptor to. It can be any client supporting Response and Request interceptors.
* @internal
*/
addForbiddenResponseInterceptor(client: typeof umbHttpClient) {
client.interceptors.response.use(async (response: Response) => {
if (response.status === 403) {
const headline = 'Permission Denied';
const message = 'You do not have the necessary permissions to complete the requested action. If you believe this is in error, please reach out to your administrator.';
this.#peekError(headline, message, null);
}
return response;
});
}
/**
* Interceptor which checks responses for the Umb-Generated-Resource header and replaces the value into the response body.
* @param {umbHttpClient} client The OpenAPI client to add the interceptor to. It can be any client supporting Response and Request interceptors.

View File

@@ -0,0 +1,3 @@
export interface UmbDataApiResponse<ResponseType extends { data: unknown } = { data: unknown }> {
data: ResponseType['data'];
}

View File

@@ -1,12 +1,9 @@
export * from './api-interceptor.controller.js';
export * from './resource.controller.js';
export * from './try-execute.controller.js';
export * from './tryExecute.function.js';
export * from './tryExecuteAndNotify.function.js';
export * from './tryXhrRequest.function.js';
export * from './extractUmbNotificationColor.function.js';
export * from './extractUmbColorVariable.function.js';
export * from './isUmbNotifications.function.js';
export * from './apiTypeValidators.function.js';
export * from './extractUmbColorVariable.function.js';
export * from './extractUmbNotificationColor.function.js';
export * from './isUmbNotifications.function.js';
export * from './resource.controller.js';
export * from './try-execute/index.js';
export * from './umb-error.js';
export type * from './types.js';

View File

@@ -0,0 +1,17 @@
import { tryExecute } from './tryExecute.function.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
/**
* Batches promises and returns a promise that resolves to an array of results
* @param {UmbControllerHost} host - The host to use for the request and where notifications will be shown
* @param {Array<Array<BatchEntryType>>} chunks - The array of chunks to process
* @param {(chunk: Array<BatchEntryType>) => Promise<PromiseResult>} callback - The function to call for each chunk
* @returns {Promise<PromiseSettledResult<PromiseResult>[]>} - A promise that resolves to an array of results
*/
export function batchTryExecute<BatchEntryType, PromiseResult>(
host: UmbControllerHost,
chunks: Array<Array<BatchEntryType>>,
callback: (chunk: Array<BatchEntryType>) => Promise<PromiseResult>,
): Promise<PromiseSettledResult<PromiseResult>[]> {
return Promise.allSettled(chunks.map((chunk) => tryExecute(host, callback(chunk), { disableNotifications: true })));
}

View File

@@ -0,0 +1,5 @@
export * from './batch-try-execute.function.js';
export * from './try-execute.controller.js';
export * from './tryExecute.function.js';
export * from './tryExecuteAndNotify.function.js';
export * from './tryXhrRequest.function.js';

View File

@@ -1,7 +1,7 @@
import { isProblemDetailsLike } from './apiTypeValidators.function.js';
import { UmbResourceController } from './resource.controller.js';
import type { UmbApiResponse, UmbTryExecuteOptions } from './types.js';
import { UmbApiError, UmbCancelError } from './umb-error.js';
import { isProblemDetailsLike } from '../apiTypeValidators.function.js';
import { UmbResourceController } from '../resource.controller.js';
import type { UmbApiResponse, UmbTryExecuteOptions } from '../types.js';
import { UmbApiError, UmbCancelError } from '../umb-error.js';
export class UmbTryExecuteController<T> extends UmbResourceController<T> {
#abortSignal?: AbortSignal;

Some files were not shown because too many files have changed in this diff Show More